Open Media Grabber

Download video, audio and images from TikTok, Instagram, X, Reddit, Facebook and more. Runs entirely in your browser: no backend, no tracking, no referral links.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name            Open Media Grabber
// @namespace       https://github.com/EfM8caQE9F1v4pFt5x7WrjDI1N2kFqYdydVj/open-media-grabber
// @version         0.2.1
// @description     Download video, audio and images from TikTok, Instagram, X, Reddit, Facebook and more. Runs entirely in your browser: no backend, no tracking, no referral links.
// @description:it  Scarica video, audio e immagini da TikTok, Instagram, X, Reddit, Facebook e altri. Funziona interamente nel browser: nessun backend, nessun tracciamento, nessun link referral.
// @author          Open Media Grabber contributors
// @license         GPL-3.0-or-later
// @homepageURL     https://github.com/EfM8caQE9F1v4pFt5x7WrjDI1N2kFqYdydVj/open-media-grabber
// @supportURL      https://github.com/EfM8caQE9F1v4pFt5x7WrjDI1N2kFqYdydVj/open-media-grabber/issues
// @icon            data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath fill="%236d3fd4" d="M12 3v10.6l3.3-3.3 1.4 1.4L12 17.4l-4.7-4.7 1.4-1.4 3.3 3.3V3zM5 19h14v2H5z"/%3E%3C/svg%3E
// @run-at          document-start
// @noframes
// @match           https://*.tiktok.com/*
// @match           https://*.instagram.com/*
// @match           https://*.threads.net/*
// @match           https://*.threads.com/*
// @match           https://twitter.com/*
// @match           https://*.twitter.com/*
// @match           https://x.com/*
// @match           https://*.x.com/*
// @match           https://*.reddit.com/*
// @match           https://*.facebook.com/*
// @match           https://fb.watch/*
// @grant           GM_download
// @grant           GM_xmlhttpRequest
// @grant           GM_setValue
// @grant           GM_getValue
// @grant           GM_registerMenuCommand
// @grant           GM_info
// @connect         *
// ==/UserScript==


/*
 * open-media-grabber v0.2.1
 * Zero-backend userscript that downloads media from social platforms entirely in your browser. No referrals, no telemetry, no remote services.
 *
 * Copyright (C) Open Media Grabber contributors
 *
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, either version 3 of the License, or (at your option)
 * any later version. See <https://www.gnu.org/licenses/> for details.
 *
 * This file is generated from the sources in src/. Do not edit it by hand;
 * edit the source and run `npm run build`. Sources: https://github.com/EfM8caQE9F1v4pFt5x7WrjDI1N2kFqYdydVj/open-media-grabber
 */

(() => {
  var __defProp = Object.defineProperty;
  var __getOwnPropNames = Object.getOwnPropertyNames;
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
  var __esm = (fn, res) => function __init() {
    return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
  };
  var __export = (target, all) => {
    for (var name in all)
      __defProp(target, name, { get: all[name], enumerable: true });
  };

  // src/core/log.js
  function setLogLevel(name) {
    threshold = LEVELS[name] ?? LEVELS.silent;
  }
  var LEVELS, PREFIX, STYLE, threshold, log;
  var init_log = __esm({
    "src/core/log.js"() {
      LEVELS = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 };
      PREFIX = "%c[OMG]";
      STYLE = "color:#8b5cf6;font-weight:600";
      threshold = LEVELS.silent;
      __name(setLogLevel, "setLogLevel");
      log = {
        error(...args) {
          if (threshold >= LEVELS.error) console.error(PREFIX, STYLE, ...args);
        },
        warn(...args) {
          if (threshold >= LEVELS.warn) console.warn(PREFIX, STYLE, ...args);
        },
        info(...args) {
          if (threshold >= LEVELS.info) console.info(PREFIX, STYLE, ...args);
        },
        debug(...args) {
          if (threshold >= LEVELS.debug) console.debug(PREFIX, STYLE, ...args);
        },
        /** Returns true when the level is active, so callers can skip expensive work. */
        enabled(name) {
          return threshold >= (LEVELS[name] ?? 0);
        }
      };
    }
  });

  // src/core/env.js
  function pick(name) {
    const fn = g[name];
    return typeof fn === "function" ? fn : null;
  }
  var g, gm, caps, scriptInfo, pageWindow;
  var init_env = __esm({
    "src/core/env.js"() {
      g = typeof globalThis !== "undefined" ? globalThis : {};
      __name(pick, "pick");
      gm = {
        info: g.GM_info ?? null,
        download: pick("GM_download"),
        xhr: pick("GM_xmlhttpRequest"),
        setValue: pick("GM_setValue"),
        getValue: pick("GM_getValue"),
        deleteValue: pick("GM_deleteValue"),
        listValues: pick("GM_listValues"),
        registerMenuCommand: pick("GM_registerMenuCommand"),
        addStyle: pick("GM_addStyle"),
        notification: pick("GM_notification"),
        setClipboard: pick("GM_setClipboard")
      };
      caps = Object.freeze({
        fileSystemAccess: typeof g.showSaveFilePicker === "function" && g.isSecureContext !== false,
        webCrypto: !!(g.crypto && g.crypto.subtle),
        streams: typeof g.ReadableStream === "function" && typeof g.WritableStream === "function",
        performanceObserver: typeof g.PerformanceObserver === "function" && Array.isArray(g.PerformanceObserver.supportedEntryTypes) && g.PerformanceObserver.supportedEntryTypes.includes("resource"),
        mediaSource: typeof g.MediaSource === "function",
        gmDownload: !!pick("GM_download"),
        gmXhr: !!pick("GM_xmlhttpRequest")
      });
      scriptInfo = Object.freeze({
        name: gm.info?.script?.name ?? "Open Media Grabber",
        version: gm.info?.script?.version ?? "0.0.0-dev",
        handler: gm.info?.scriptHandler ?? "unknown",
        handlerVersion: gm.info?.version ?? "0",
        downloadMode: gm.info?.downloadMode ?? "native"
      });
      pageWindow = g.unsafeWindow ?? g.window ?? g;
    }
  });

  // src/core/net.js
  var net_exports = {};
  __export(net_exports, {
    HttpError: () => HttpError,
    getJson: () => getJson,
    getText: () => getText,
    gmRequest: () => gmRequest,
    mapLimit: () => mapLimit,
    openStream: () => openStream,
    parseHeaders: () => parseHeaders,
    probe: () => probe,
    readRange: () => readRange,
    streamOf: () => streamOf,
    withRetry: () => withRetry
  });
  function isTransportFailure(err) {
    return !(err instanceof HttpError) && err?.name !== "AbortError";
  }
  function hostOf(url) {
    try {
      return new URL(url, location.href).host;
    } catch {
      return url;
    }
  }
  function gmRequest({
    url,
    method = "GET",
    headers,
    responseType = "arraybuffer",
    timeout = 6e4,
    data,
    anonymous = false,
    signal,
    onProgress
  }) {
    if (!gm.xhr) return Promise.reject(new Error("GM_xmlhttpRequest is not granted"));
    return new Promise((resolve3, reject) => {
      const control = gm.xhr({
        url,
        method,
        headers,
        responseType,
        timeout,
        data,
        anonymous,
        onload(res) {
          if (res.status >= 200 && res.status < 400) {
            resolve3({
              status: res.status,
              response: res.response,
              headers: res.responseHeaders || "",
              finalUrl: res.finalUrl || url
            });
          } else {
            reject(new HttpError(res.status, url, res.statusText));
          }
        },
        onerror: /* @__PURE__ */ __name((res) => reject(new HttpError(res?.status ?? 0, url, "network error")), "onerror"),
        ontimeout: /* @__PURE__ */ __name(() => reject(new Error(`Timeout after ${timeout}ms for ${url}`)), "ontimeout"),
        onabort: /* @__PURE__ */ __name(() => reject(new DOMException("Aborted", "AbortError")), "onabort"),
        onprogress: onProgress ? (p) => onProgress({ loaded: p.loaded, total: p.lengthComputable ? p.total : 0 }) : void 0
      });
      if (signal) {
        if (signal.aborted) control.abort();
        else signal.addEventListener("abort", () => control.abort(), { once: true });
      }
    });
  }
  function parseHeaders(raw) {
    const out = /* @__PURE__ */ new Map();
    if (!raw) return out;
    for (const line of raw.split(/\r?\n/)) {
      const idx = line.indexOf(":");
      if (idx <= 0) continue;
      out.set(line.slice(0, idx).trim().toLowerCase(), line.slice(idx + 1).trim());
    }
    return out;
  }
  async function getText(url, { headers, signal, referer } = {}) {
    const host = hostOf(url);
    if (!corsBlocked.has(host)) {
      try {
        const res2 = await fetch(url, {
          headers,
          signal,
          credentials: "include",
          referrer: referer
        });
        if (!res2.ok) throw new HttpError(res2.status, url, res2.statusText);
        return await res2.text();
      } catch (err) {
        if (!isTransportFailure(err)) throw err;
        log.debug("fetch blocked, switching to GM for", host, err?.message);
        corsBlocked.add(host);
      }
    }
    const res = await gmRequest({
      url,
      responseType: "text",
      headers: referer ? { ...headers, Referer: referer } : headers,
      signal
    });
    return res.response;
  }
  async function getJson(url, opts) {
    return JSON.parse(await getText(url, opts));
  }
  async function probe(url, { headers, signal, referer } = {}) {
    const merged = referer ? { ...headers, Referer: referer } : headers;
    const readFetch = /* @__PURE__ */ __name(async (method, extra) => {
      const res2 = await fetch(url, {
        method,
        headers: { ...merged, ...extra },
        signal,
        credentials: "include"
      });
      if (!res2.ok && res2.status !== 206) throw new HttpError(res2.status, url, res2.statusText);
      return {
        size: sizeFrom(res2.headers.get("content-range"), res2.headers.get("content-length")),
        acceptsRanges: res2.status === 206 || (res2.headers.get("accept-ranges") || "").includes("bytes"),
        type: res2.headers.get("content-type") || "",
        finalUrl: res2.url || url
      };
    }, "readFetch");
    const host = hostOf(url);
    if (!corsBlocked.has(host)) {
      try {
        return await readFetch("GET", { Range: "bytes=0-0" });
      } catch (err) {
        if (!isTransportFailure(err)) throw err;
        corsBlocked.add(host);
      }
    }
    const res = await gmRequest({
      url,
      method: "GET",
      headers: { ...merged, Range: "bytes=0-0" },
      responseType: "arraybuffer",
      signal
    });
    const h = parseHeaders(res.headers);
    return {
      size: sizeFrom(h.get("content-range"), h.get("content-length")),
      acceptsRanges: res.status === 206 || (h.get("accept-ranges") || "").includes("bytes"),
      type: h.get("content-type") || "",
      finalUrl: res.finalUrl
    };
  }
  function sizeFrom(contentRange, contentLength) {
    if (contentRange) {
      const m = /\/(\d+)\s*$/.exec(contentRange);
      if (m) return Number(m[1]);
    }
    const n = Number(contentLength);
    return Number.isFinite(n) && n > 0 ? n : 0;
  }
  async function readRange(url, start2, end, { headers, signal, referer } = {}) {
    const range = `bytes=${start2}-${end}`;
    const merged = { ...headers, Range: range };
    if (referer) merged.Referer = referer;
    const host = hostOf(url);
    if (!corsBlocked.has(host)) {
      try {
        const res2 = await fetch(url, { headers: merged, signal, credentials: "include" });
        if (!res2.ok && res2.status !== 206) throw new HttpError(res2.status, url, res2.statusText);
        return new Uint8Array(await res2.arrayBuffer());
      } catch (err) {
        if (!isTransportFailure(err)) throw err;
        corsBlocked.add(host);
      }
    }
    const res = await gmRequest({
      url,
      headers: merged,
      responseType: "arraybuffer",
      signal
    });
    return new Uint8Array(res.response);
  }
  async function openStream(url, { headers, signal, referer, chunkSize = 4 * 1024 * 1024, size = 0 } = {}) {
    const merged = referer ? { ...headers, Referer: referer } : headers;
    const host = hostOf(url);
    if (!corsBlocked.has(host)) {
      try {
        const res = await fetch(url, { headers: merged, signal, credentials: "include" });
        if (!res.ok) throw new HttpError(res.status, url, res.statusText);
        if (res.body) return res.body;
        const buf = new Uint8Array(await res.arrayBuffer());
        return streamOf(buf);
      } catch (err) {
        if (!isTransportFailure(err)) throw err;
        corsBlocked.add(host);
      }
    }
    let total = size;
    if (!total) {
      const info = await probe(url, { headers, signal, referer });
      total = info.size;
      if (!info.acceptsRanges || !total) {
        const res = await gmRequest({ url, headers: merged, responseType: "arraybuffer", signal });
        return streamOf(new Uint8Array(res.response));
      }
    }
    let offset = 0;
    return new ReadableStream({
      async pull(controller) {
        if (offset >= total) {
          controller.close();
          return;
        }
        const end = Math.min(offset + chunkSize, total) - 1;
        const chunk = await readRange(url, offset, end, { headers, signal, referer });
        offset = end + 1;
        controller.enqueue(chunk);
      },
      cancel() {
        offset = total;
      }
    });
  }
  function streamOf(bytes) {
    return new ReadableStream({
      start(controller) {
        controller.enqueue(bytes);
        controller.close();
      }
    });
  }
  async function mapLimit(items, limit, worker) {
    const results = new Array(items.length);
    let cursor = 0;
    const runners = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
      for (; ; ) {
        const index = cursor++;
        if (index >= items.length) return;
        results[index] = await worker(items[index], index);
      }
    });
    await Promise.all(runners);
    return results;
  }
  async function withRetry(fn, { attempts = 3, baseDelay = 400, signal } = {}) {
    let lastErr;
    for (let i = 0; i < attempts; i++) {
      try {
        return await fn(i);
      } catch (err) {
        if (err?.name === "AbortError") throw err;
        if (err instanceof HttpError && err.status >= 400 && err.status < 500) throw err;
        lastErr = err;
        if (i === attempts - 1) break;
        const delay = baseDelay * 2 ** i;
        await new Promise((resolve3, reject) => {
          const timer = setTimeout(resolve3, delay);
          signal?.addEventListener(
            "abort",
            () => {
              clearTimeout(timer);
              reject(new DOMException("Aborted", "AbortError"));
            },
            { once: true }
          );
        });
      }
    }
    throw lastErr;
  }
  var corsBlocked, _HttpError, HttpError;
  var init_net = __esm({
    "src/core/net.js"() {
      init_env();
      init_log();
      corsBlocked = /* @__PURE__ */ new Set();
      __name(isTransportFailure, "isTransportFailure");
      _HttpError = class _HttpError extends Error {
        constructor(status, url, statusText = "") {
          super(`HTTP ${status}${statusText ? ` ${statusText}` : ""} for ${url}`);
          this.name = "HttpError";
          this.status = status;
          this.url = url;
        }
      };
      __name(_HttpError, "HttpError");
      HttpError = _HttpError;
      __name(hostOf, "hostOf");
      __name(gmRequest, "gmRequest");
      __name(parseHeaders, "parseHeaders");
      __name(getText, "getText");
      __name(getJson, "getJson");
      __name(probe, "probe");
      __name(sizeFrom, "sizeFrom");
      __name(readRange, "readRange");
      __name(openStream, "openStream");
      __name(streamOf, "streamOf");
      __name(mapLimit, "mapLimit");
      __name(withRetry, "withRetry");
    }
  });

  // src/core/bus.js
  init_log();
  function createBus() {
    const channels = /* @__PURE__ */ new Map();
    return {
      on(event, handler) {
        let set2 = channels.get(event);
        if (!set2) channels.set(event, set2 = /* @__PURE__ */ new Set());
        set2.add(handler);
        return () => set2.delete(handler);
      },
      once(event, handler) {
        const off = this.on(event, (...args) => {
          off();
          handler(...args);
        });
        return off;
      },
      emit(event, payload) {
        const set2 = channels.get(event);
        if (!set2 || set2.size === 0) return;
        for (const handler of set2) {
          try {
            handler(payload);
          } catch (err) {
            log.error(`handler for "${event}" threw`, err);
          }
        }
      },
      clear() {
        channels.clear();
      }
    };
  }
  __name(createBus, "createBus");
  var bus = createBus();
  var EVENTS = Object.freeze({
    /** A candidate media URL was observed on the network. */
    MEDIA_SEEN: "media:seen",
    /** An adapter published a resolved, downloadable asset. */
    ASSET_ADDED: "asset:added",
    /** The asset list changed in any way (add, clear, reorder). */
    ASSETS_CHANGED: "assets:changed",
    /** SPA navigation happened; adapters should re-scan. */
    NAVIGATED: "app:navigated",
    /** Download lifecycle. */
    JOB_UPDATED: "job:updated"
  });

  // src/main.js
  init_env();
  init_log();

  // src/core/registry.js
  init_log();
  var adapters = [];
  function register(adapter) {
    adapters.push(adapter);
    return adapter;
  }
  __name(register, "register");
  function listAdapters() {
    return adapters.slice();
  }
  __name(listAdapters, "listAdapters");
  function adapterFor(url) {
    for (const adapter of adapters) {
      try {
        if (adapter.matches(url)) return adapter;
      } catch (err) {
        log.debug(`adapter ${adapter.id} matcher threw`, err);
      }
    }
    return null;
  }
  __name(adapterFor, "adapterFor");
  var assets = /* @__PURE__ */ new Map();
  function keyOf(asset) {
    return asset.url || asset.manifestUrl || `${asset.videoUrl || ""}|${asset.audioUrl || ""}` || asset.id;
  }
  __name(keyOf, "keyOf");
  function publishAssets(list) {
    let changed = false;
    for (const asset of list) {
      const key = keyOf(asset);
      if (!key || assets.has(key)) continue;
      const stored = { ...asset, id: asset.id || key };
      assets.set(key, stored);
      bus.emit(EVENTS.ASSET_ADDED, stored);
      changed = true;
    }
    if (changed) bus.emit(EVENTS.ASSETS_CHANGED, getAssets());
  }
  __name(publishAssets, "publishAssets");
  function getAssets() {
    return [...assets.values()];
  }
  __name(getAssets, "getAssets");
  function clearAssets() {
    if (!assets.size) return;
    assets.clear();
    bus.emit(EVENTS.ASSETS_CHANGED, []);
  }
  __name(clearAssets, "clearAssets");

  // src/core/sniffer.js
  init_env();
  init_log();

  // src/core/store.js
  init_env();
  init_log();
  var DEFAULTS = Object.freeze({
    /** `auto` picks the highest resolution that is not obviously a duplicate. */
    preferredQuality: "auto",
    // auto | highest | lowest | <height as string>
    /** Template for generated filenames. See media/filename.js for the fields. */
    filenameTemplate: "{platform}-{author}-{title}-{id}",
    /** Ask where to save (File System Access) instead of dropping in Downloads. */
    askWhereToSave: false,
    /** Patch fetch/XHR to read response bodies. Off = passive observation only. */
    deepInspection: true,
    /** Parallel segment fetches for HLS/DASH. Higher is faster but hotter. */
    segmentConcurrency: 4,
    /** Bytes buffered before flushing to the sink. */
    chunkSize: 4 * 1024 * 1024,
    /** Refuse to buffer more than this in RAM when streaming to disk is absent. */
    maxBufferedBytes: 512 * 1024 * 1024,
    /** Merge separate video+audio tracks into one MP4 when possible. */
    autoMerge: true,
    /** Floating button visibility. */
    showLauncher: true,
    /** UI language; `auto` follows navigator.language. */
    locale: "auto",
    /** silent | error | warn | info | debug */
    logLevel: "silent"
  });
  var KEY = "settings";
  var cache = { ...DEFAULTS };
  async function loadSettings() {
    if (!gm.getValue) return cache;
    try {
      const raw = await gm.getValue(KEY, null);
      if (raw && typeof raw === "object") {
        cache = { ...DEFAULTS, ...raw };
        for (const key of Object.keys(cache)) {
          if (!(key in DEFAULTS)) delete cache[key];
        }
      }
    } catch (err) {
      log.warn("could not load settings, using defaults", err);
    }
    return cache;
  }
  __name(loadSettings, "loadSettings");
  function getSettings() {
    return cache;
  }
  __name(getSettings, "getSettings");
  function get(key) {
    return cache[key];
  }
  __name(get, "get");
  async function set(patch) {
    cache = { ...cache, ...patch };
    if (!gm.setValue) return cache;
    try {
      await gm.setValue(KEY, cache);
    } catch (err) {
      log.warn("could not persist settings", err);
    }
    return cache;
  }
  __name(set, "set");
  async function reset() {
    cache = { ...DEFAULTS };
    if (gm.setValue) await gm.setValue(KEY, cache);
    return cache;
  }
  __name(reset, "reset");

  // src/core/sniffer.js
  var MEDIA_EXT = /\.(m3u8|mpd|mp4|m4s|m4a|m4v|webm|mov|ts|aac|mp3|opus|ogg|flac|wav|jpe?g|png|gif|webp|avif|heic)(\?|#|$)/i;
  var MEDIA_MIME = /^(video\/|audio\/|image\/|application\/(x-mpegurl|vnd\.apple\.mpegurl|dash\+xml|octet-stream))/i;
  var seen = /* @__PURE__ */ new Set();
  var bodyWatchers = [];
  var passiveStarted = false;
  var deepStarted = false;
  function remember(url) {
    if (seen.has(url)) return false;
    if (seen.size > 4e3) seen.clear();
    seen.add(url);
    return true;
  }
  __name(remember, "remember");
  function canonicalise(rawUrl) {
    try {
      const u = new URL(rawUrl, location.href);
      for (const key of ["range", "rn", "rbuf", "sq", "ump", "srfvp", "_nc_cb"]) {
        u.searchParams.delete(key);
      }
      u.hash = "";
      return u.toString();
    } catch {
      return rawUrl;
    }
  }
  __name(canonicalise, "canonicalise");
  function classify(url, mime = "") {
    const lower = url.toLowerCase();
    if (/\.m3u8(\?|#|$)/.test(lower) || /mpegurl/i.test(mime)) return "hls";
    if (/\.mpd(\?|#|$)/.test(lower) || /dash\+xml/i.test(mime)) return "dash";
    if (/^image\//i.test(mime) || /\.(jpe?g|png|gif|webp|avif|heic)(\?|#|$)/.test(lower)) {
      return "image";
    }
    if (/^audio\//i.test(mime) || /\.(m4a|aac|mp3|opus|ogg|flac|wav)(\?|#|$)/.test(lower)) {
      return "audio";
    }
    return "video";
  }
  __name(classify, "classify");
  function report(rawUrl, { mime = "", size = 0, source = "network" } = {}) {
    if (!rawUrl || rawUrl.startsWith("blob:") || rawUrl.startsWith("data:")) return;
    if (!MEDIA_EXT.test(rawUrl) && !MEDIA_MIME.test(mime)) return;
    const url = canonicalise(rawUrl);
    if (!remember(url)) return;
    bus.emit(EVENTS.MEDIA_SEEN, { url, kind: classify(url, mime), mime, size, source });
  }
  __name(report, "report");
  function startPassive() {
    if (passiveStarted || !caps.performanceObserver) return;
    passiveStarted = true;
    const observer2 = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.initiatorType === "css" || entry.initiatorType === "script") continue;
        report(entry.name, {
          size: entry.encodedBodySize || 0,
          source: "performance"
        });
      }
    });
    try {
      observer2.observe({ type: "resource", buffered: true });
    } catch (err) {
      log.warn("PerformanceObserver unavailable", err);
      passiveStarted = false;
    }
  }
  __name(startPassive, "startPassive");
  function wantsBody(url) {
    for (const watcher of bodyWatchers) {
      try {
        if (watcher.test(url)) return watcher;
      } catch {
      }
    }
    return null;
  }
  __name(wantsBody, "wantsBody");
  function deliverBody(watcher, url, text) {
    try {
      watcher.handler(url, text);
    } catch (err) {
      log.debug("body watcher threw", err);
    }
  }
  __name(deliverBody, "deliverBody");
  function patchFetch() {
    const original = pageWindow.fetch;
    if (typeof original !== "function" || original.__omgPatched) return;
    const patched = /* @__PURE__ */ __name(async function fetch2(input, init) {
      const response = await original.call(this, input, init);
      try {
        const url = typeof input === "string" ? input : input instanceof Request ? input.url : String(input?.url ?? "");
        report(url, { mime: response.headers?.get?.("content-type") || "" });
        const watcher = wantsBody(url);
        if (watcher && response.ok && response.body) {
          response.clone().text().then((text) => deliverBody(watcher, url, text)).catch(() => {
          });
        }
      } catch {
      }
      return response;
    }, "fetch");
    patched.__omgPatched = true;
    Object.defineProperty(patched, "name", { value: "fetch" });
    pageWindow.fetch = patched;
  }
  __name(patchFetch, "patchFetch");
  function patchXhr() {
    const XHR = pageWindow.XMLHttpRequest;
    if (!XHR || XHR.prototype.__omgPatched) return;
    const open2 = XHR.prototype.open;
    const send = XHR.prototype.send;
    XHR.prototype.open = function(method, url, ...rest) {
      this.__omgUrl = String(url);
      return open2.call(this, method, url, ...rest);
    };
    XHR.prototype.send = function(...args) {
      const url = this.__omgUrl;
      if (url) {
        this.addEventListener(
          "load",
          () => {
            try {
              report(url, { mime: this.getResponseHeader?.("content-type") || "" });
              const watcher = wantsBody(url);
              if (watcher && (this.responseType === "" || this.responseType === "text")) {
                deliverBody(watcher, url, this.responseText);
              }
            } catch {
            }
          },
          { once: true }
        );
      }
      return send.apply(this, args);
    };
    XHR.prototype.__omgPatched = true;
  }
  __name(patchXhr, "patchXhr");
  function patchMediaSource() {
    const MS = pageWindow.MediaSource;
    if (!MS || MS.prototype.__omgPatched) return;
    const addSourceBuffer = MS.prototype.addSourceBuffer;
    MS.prototype.addSourceBuffer = function(mime) {
      const buffer = addSourceBuffer.call(this, mime);
      bus.emit(EVENTS.MEDIA_SEEN, {
        url: "",
        kind: /audio/i.test(mime) ? "audio" : "video",
        mime,
        size: 0,
        source: "mse",
        codecs: mime
      });
      return buffer;
    };
    MS.prototype.__omgPatched = true;
  }
  __name(patchMediaSource, "patchMediaSource");
  function startDeep() {
    if (deepStarted) return;
    deepStarted = true;
    for (const [name, patch] of [
      ["fetch", patchFetch],
      ["XMLHttpRequest", patchXhr],
      ["MediaSource", patchMediaSource]
    ]) {
      try {
        patch();
      } catch (err) {
        log.warn(`could not observe ${name}; continuing without it`, err);
      }
    }
    log.debug("deep inspection attached");
  }
  __name(startDeep, "startDeep");
  function watchBody(test, handler) {
    bodyWatchers.push({ test, handler });
    if (get("deepInspection")) startDeep();
    return () => {
      const i = bodyWatchers.findIndex((w) => w.handler === handler);
      if (i >= 0) bodyWatchers.splice(i, 1);
    };
  }
  __name(watchBody, "watchBody");
  function startSniffer() {
    startPassive();
    if (get("deepInspection") && bodyWatchers.length) startDeep();
  }
  __name(startSniffer, "startSniffer");
  function enableDeepInspection() {
    startDeep();
  }
  __name(enableDeepInspection, "enableDeepInspection");
  function resetSeen() {
    seen.clear();
  }
  __name(resetSeen, "resetSeen");

  // src/ui/inline.js
  init_log();
  var MARKER = "data-omg-anchored";
  var BUTTON_CLASS = "omg-inline-button";
  var anchors = [];
  var observer = null;
  var scanQueued = false;
  var reportedScanFailure = false;
  var INLINE_CSS = `
.${BUTTON_CLASS} {
  /* These buttons live in the page's DOM, where our reset does not reach and
     the host's own rules may be anything at all. Every property that affects
     geometry is therefore stated explicitly, box-sizing included — leaving it
     to inherit once made the icon overflow its own button. */
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: var(--omg-hit, 34px);
  height: var(--omg-hit, 34px);
  padding: 0;
  margin: 0;
  background: none;
  border: none;
  border-radius: 999px;
  color: inherit;
  font: inherit;
  line-height: 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background-color 120ms ease, color 120ms ease;
}
.${BUTTON_CLASS} > svg {
  box-sizing: border-box;
  width: var(--omg-icon, 20px);
  height: var(--omg-icon, 20px);
  display: block;
  overflow: visible;
  pointer-events: none;
}
.${BUTTON_CLASS}:hover { background-color: color-mix(in srgb, currentColor 12%, transparent); }
.${BUTTON_CLASS}:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}
.${BUTTON_CLASS}[data-omg-busy="1"] { opacity: 0.45; cursor: progress; }

@media (prefers-reduced-motion: reduce) {
  .${BUTTON_CLASS} { transition: none; }
}
`;
  function ensureStyles() {
    if (document.getElementById("omg-inline-style")) return;
    const style = document.createElement("style");
    style.id = "omg-inline-style";
    style.textContent = INLINE_CSS;
    (document.head || document.documentElement).appendChild(style);
  }
  __name(ensureStyles, "ensureStyles");
  var SVG_NS = "http://www.w3.org/2000/svg";
  var ICONS = {
    solid: [
      // Arrow: shaft, then the head, as one closed outline.
      "M11 5h2v6h3l-4 4.6L8 11h3z",
      // Tray.
      "M6.5 17.4h11v2h-11z"
    ],
    outline: [
      "M12 3.9v10",
      "M7.9 9.9 12 14l4.1-4.1",
      "M5 16.4v2.2c0 1 .8 1.8 1.8 1.8h10.4c1 0 1.8-.8 1.8-1.8v-2.2"
    ]
  };
  function buildIcon(variant = "outline") {
    const solid = variant === "solid";
    const svg = document.createElementNS(SVG_NS, "svg");
    svg.setAttribute("viewBox", "0 0 24 24");
    svg.setAttribute("aria-hidden", "true");
    svg.setAttribute("focusable", "false");
    svg.setAttribute("fill", solid ? "currentColor" : "none");
    if (!solid) {
      svg.setAttribute("stroke", "currentColor");
      svg.setAttribute("stroke-width", "2");
      svg.setAttribute("stroke-linecap", "round");
      svg.setAttribute("stroke-linejoin", "round");
    }
    for (const d of ICONS[solid ? "solid" : "outline"]) {
      const path = document.createElementNS(SVG_NS, "path");
      path.setAttribute("d", d);
      svg.appendChild(path);
    }
    return svg;
  }
  __name(buildIcon, "buildIcon");
  function buildButton(anchor, host, onActivate) {
    const button = document.createElement("button");
    button.type = "button";
    button.className = `${BUTTON_CLASS}${anchor.className ? ` ${anchor.className}` : ""}`;
    button.setAttribute("aria-label", anchor.label);
    button.title = anchor.label;
    if (anchor.hitSize) button.style.setProperty("--omg-hit", anchor.hitSize);
    if (anchor.iconSize) button.style.setProperty("--omg-icon", anchor.iconSize);
    button.appendChild(buildIcon(anchor.icon));
    if (anchor.text) {
      const span = document.createElement("span");
      span.textContent = anchor.text;
      button.appendChild(span);
    }
    button.addEventListener("click", (event) => {
      event.preventDefault();
      event.stopPropagation();
      onActivate(button, host, event);
    });
    button.addEventListener("contextmenu", (event) => {
      event.preventDefault();
      event.stopPropagation();
      onActivate(button, host, { ...event, altKey: true, wantsChooser: true });
    });
    return button;
  }
  __name(buildButton, "buildButton");
  function resolveContainers(anchor) {
    try {
      if (typeof anchor.container === "function") return anchor.container() ?? [];
      return document.querySelectorAll(anchor.container);
    } catch (err) {
      log.debug(`anchor ${anchor.id} container lookup failed`, err);
      return [];
    }
  }
  __name(resolveContainers, "resolveContainers");
  function alreadyHasButton(container2) {
    return !!container2.querySelector(`.${BUTTON_CLASS}`);
  }
  __name(alreadyHasButton, "alreadyHasButton");
  function adoptNeighbourSize(button, container2) {
    const sizes = [...container2.querySelectorAll("svg")].filter((svg) => !button.contains(svg)).map((svg) => svg.getBoundingClientRect()).filter((box) => box.width >= 10 && box.width <= 64).map((box) => Math.max(box.width, box.height)).sort((a, b) => a - b);
    if (!sizes.length) return;
    const median = sizes[Math.floor(sizes.length / 2)];
    const icon = Math.round(Math.min(Math.max(median, 16), 34));
    button.style.setProperty("--omg-icon", `${icon}px`);
    const hostSizes = [...container2.querySelectorAll('button, [role="button"], a')].filter((node) => !node.contains(button)).map((node) => node.getBoundingClientRect().height).filter((h) => h >= 20 && h <= 72).sort((a, b) => a - b);
    const hit = hostSizes.length ? Math.round(hostSizes[Math.floor(hostSizes.length / 2)]) : Math.max(24, icon + 12);
    button.style.setProperty("--omg-hit", `${Math.min(Math.max(hit, 24), 64)}px`);
  }
  __name(adoptNeighbourSize, "adoptNeighbourSize");
  function injectInto(anchor, container2, onActivate) {
    if (!container2 || !container2.isConnected) return false;
    if (alreadyHasButton(container2)) return false;
    let reference = null;
    if (anchor.reference) {
      reference = typeof anchor.reference === "function" ? anchor.reference(container2) : container2.querySelector(anchor.reference);
      if (!reference && anchor.requireReference !== false) return false;
    }
    const button = buildButton(anchor, container2, onActivate);
    try {
      if (reference) {
        reference.insertAdjacentElement(anchor.position || "afterend", button);
      } else {
        container2.appendChild(button);
      }
    } catch (err) {
      log.debug(`anchor ${anchor.id} insertion failed`, err);
      return false;
    }
    if (anchor.matchNeighbourSize !== false) adoptNeighbourSize(button, container2);
    container2.setAttribute(MARKER, anchor.id);
    return true;
  }
  __name(injectInto, "injectInto");
  function scan(onActivate) {
    ensureStyles();
    let injected = 0;
    for (const anchor of anchors) {
      for (const container2 of resolveContainers(anchor)) {
        if (injectInto(anchor, container2, onActivate)) injected++;
      }
    }
    if (injected && log.enabled("debug")) log.debug(`injected ${injected} inline button(s)`);
    return injected;
  }
  __name(scan, "scan");
  function startInlineButtons(specs, onActivate) {
    anchors = specs.filter(Boolean);
    if (!anchors.length) return () => {
    };
    const activate = /* @__PURE__ */ __name((button, host, event) => {
      const anchor = anchors.find((a) => a.id === host.getAttribute(MARKER)) ?? anchors[0];
      onActivate(button, host, anchor, event);
    }, "activate");
    const requestScan = /* @__PURE__ */ __name(() => {
      if (scanQueued) return;
      scanQueued = true;
      requestAnimationFrame(() => {
        scanQueued = false;
        try {
          scan(activate);
        } catch (err) {
          if (!reportedScanFailure) {
            reportedScanFailure = true;
            console.warn("[Open Media Grabber] could not add inline buttons:", err);
          }
          log.debug("inline scan failed", err);
        }
      });
    }, "requestScan");
    requestScan();
    observer = new MutationObserver((records) => {
      for (const record of records) {
        for (const node of record.addedNodes) {
          if (node.nodeType !== 1) continue;
          if (node.classList?.contains(BUTTON_CLASS)) continue;
          requestScan();
          return;
        }
      }
    });
    observer.observe(document.documentElement, { childList: true, subtree: true });
    return () => {
      observer?.disconnect();
      observer = null;
      anchors = [];
    };
  }
  __name(startInlineButtons, "startInlineButtons");
  function clearInlineButtons() {
    for (const button of document.querySelectorAll(`.${BUTTON_CLASS}`)) button.remove();
    for (const marked of document.querySelectorAll(`[${MARKER}]`)) marked.removeAttribute(MARKER);
  }
  __name(clearInlineButtons, "clearInlineButtons");
  function setButtonBusy(button, busy) {
    if (busy) button.setAttribute("data-omg-busy", "1");
    else button.removeAttribute("data-omg-busy");
  }
  __name(setButtonBusy, "setButtonBusy");

  // src/ui/panel.js
  init_env();
  init_log();

  // src/media/pipeline.js
  init_log();
  init_net();

  // src/media/hls.js
  var DRM_KEYFORMATS = /widevine|playready|fairplay|com\.apple\.streamingkeydelivery/i;
  function splitAttributes(input) {
    const out = {};
    let key = "";
    let value = "";
    let inKey = true;
    let quoted = false;
    for (let i = 0; i <= input.length; i++) {
      const ch = input[i];
      if (i === input.length || ch === "," && !quoted) {
        if (key) out[key.trim()] = value.trim().replace(/^"|"$/g, "");
        key = "";
        value = "";
        inKey = true;
        continue;
      }
      if (ch === '"') {
        quoted = !quoted;
        value += ch;
        continue;
      }
      if (ch === "=" && inKey && !quoted) {
        inKey = false;
        continue;
      }
      if (inKey) key += ch;
      else value += ch;
    }
    return out;
  }
  __name(splitAttributes, "splitAttributes");
  function resolve(uri, base) {
    try {
      return new URL(uri, base).toString();
    } catch {
      return uri;
    }
  }
  __name(resolve, "resolve");
  function isMasterPlaylist(text) {
    return /^#EXT-X-STREAM-INF:/m.test(text);
  }
  __name(isMasterPlaylist, "isMasterPlaylist");
  function parseMaster(text, baseUrl) {
    const lines = text.split(/\r?\n/);
    const variants = [];
    const media = [];
    let pending = null;
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i].trim();
      if (!line) continue;
      if (line.startsWith("#EXT-X-STREAM-INF:")) {
        const attrs = splitAttributes(line.slice("#EXT-X-STREAM-INF:".length));
        const [w, h] = (attrs.RESOLUTION || "").split("x").map(Number);
        pending = {
          bandwidth: Number(attrs.BANDWIDTH || attrs["AVERAGE-BANDWIDTH"] || 0),
          width: w || 0,
          height: h || 0,
          codecs: attrs.CODECS || "",
          frameRate: Number(attrs["FRAME-RATE"] || 0),
          audioGroup: attrs.AUDIO || ""
        };
        continue;
      }
      if (line.startsWith("#EXT-X-MEDIA:")) {
        const attrs = splitAttributes(line.slice("#EXT-X-MEDIA:".length));
        if (attrs.URI) {
          media.push({
            type: attrs.TYPE || "",
            group: attrs["GROUP-ID"] || "",
            name: attrs.NAME || "",
            language: attrs.LANGUAGE || "",
            isDefault: attrs.DEFAULT === "YES",
            channels: Number(attrs.CHANNELS || 0),
            url: resolve(attrs.URI, baseUrl)
          });
        }
        continue;
      }
      if (line.startsWith("#")) continue;
      if (pending) {
        variants.push({ ...pending, url: resolve(line, baseUrl) });
        pending = null;
      }
    }
    variants.sort((a, b) => b.height - a.height || b.bandwidth - a.bandwidth);
    return { variants, media };
  }
  __name(parseMaster, "parseMaster");
  function parseMedia(text, baseUrl) {
    const lines = text.split(/\r?\n/);
    const segments = [];
    let initSegment = null;
    let targetDuration = 0;
    let duration = 0;
    let pendingDuration = 0;
    let pendingByteRange = null;
    let encryption = null;
    let isLive = true;
    let mediaSequence = 0;
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i].trim();
      if (!line) continue;
      if (line.startsWith("#EXTINF:")) {
        pendingDuration = parseFloat(line.slice("#EXTINF:".length)) || 0;
        continue;
      }
      if (line.startsWith("#EXT-X-TARGETDURATION:")) {
        targetDuration = Number(line.split(":")[1]) || 0;
        continue;
      }
      if (line.startsWith("#EXT-X-MEDIA-SEQUENCE:")) {
        mediaSequence = Number(line.split(":")[1]) || 0;
        continue;
      }
      if (line === "#EXT-X-ENDLIST") {
        isLive = false;
        continue;
      }
      if (line.startsWith("#EXT-X-BYTERANGE:")) {
        pendingByteRange = parseByteRange(line.slice("#EXT-X-BYTERANGE:".length), segments);
        continue;
      }
      if (line.startsWith("#EXT-X-MAP:")) {
        const attrs = splitAttributes(line.slice("#EXT-X-MAP:".length));
        initSegment = {
          url: resolve(attrs.URI, baseUrl),
          byteRange: attrs.BYTERANGE ? parseByteRange(attrs.BYTERANGE, []) : null
        };
        continue;
      }
      if (line.startsWith("#EXT-X-KEY:")) {
        const attrs = splitAttributes(line.slice("#EXT-X-KEY:".length));
        const method = (attrs.METHOD || "NONE").toUpperCase();
        if (method === "NONE") {
          encryption = null;
        } else {
          const keyFormat = attrs.KEYFORMAT || "identity";
          encryption = {
            method,
            keyFormat,
            url: attrs.URI ? resolve(attrs.URI, baseUrl) : "",
            iv: attrs.IV || "",
            /** Set when we cannot and will not handle it. */
            drm: DRM_KEYFORMATS.test(keyFormat) || method === "SAMPLE-AES"
          };
        }
        continue;
      }
      if (line.startsWith("#")) continue;
      segments.push({
        url: resolve(line, baseUrl),
        duration: pendingDuration,
        byteRange: pendingByteRange,
        sequence: mediaSequence + segments.length,
        encryption
      });
      duration += pendingDuration;
      pendingDuration = 0;
      pendingByteRange = null;
    }
    const probe2 = segments[0]?.url || initSegment?.url || "";
    const container2 = initSegment || /\.(m4s|mp4|m4a|cmf[vat])(\?|#|$)/i.test(probe2) ? "fmp4" : "ts";
    return { segments, initSegment, targetDuration, duration, encryption, isLive, container: container2 };
  }
  __name(parseMedia, "parseMedia");
  function parseByteRange(spec, previous) {
    const [lenPart, offsetPart] = spec.trim().split("@");
    const length = Number(lenPart);
    let offset = offsetPart !== void 0 ? Number(offsetPart) : null;
    if (offset === null) {
      const last = previous[previous.length - 1];
      offset = last?.byteRange ? last.byteRange.offset + last.byteRange.length : 0;
    }
    return { offset, length };
  }
  __name(parseByteRange, "parseByteRange");
  async function decryptSegment(bytes, key, ivHex, sequence) {
    const iv = ivHex ? hexToBytes(ivHex) : sequenceIv(sequence);
    const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-CBC" }, false, [
      "decrypt"
    ]);
    const plain = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, cryptoKey, bytes);
    return new Uint8Array(plain);
  }
  __name(decryptSegment, "decryptSegment");
  function hexToBytes(hex) {
    const clean = hex.replace(/^0x/i, "");
    const out = new Uint8Array(clean.length / 2);
    for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16);
    return out;
  }
  __name(hexToBytes, "hexToBytes");
  function sequenceIv(sequence) {
    const iv = new Uint8Array(16);
    new DataView(iv.buffer).setUint32(12, sequence >>> 0, false);
    return iv;
  }
  __name(sequenceIv, "sequenceIv");

  // src/media/dash.js
  function children(node, name) {
    const out = [];
    for (const child of node.children) {
      if (child.localName === name) out.push(child);
    }
    return out;
  }
  __name(children, "children");
  function firstChild(node, name) {
    return children(node, name)[0] || null;
  }
  __name(firstChild, "firstChild");
  function attr(node, name, fallback = "") {
    return node?.getAttribute?.(name) ?? fallback;
  }
  __name(attr, "attr");
  function num(node, name, fallback = 0) {
    const v = Number(attr(node, name, ""));
    return Number.isFinite(v) ? v : fallback;
  }
  __name(num, "num");
  function parseDuration(value) {
    if (!value) return 0;
    const m = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/.exec(
      value
    );
    if (!m) return 0;
    const [, y, mo, d, h, mi, s] = m;
    return (Number(y) || 0) * 31536e3 + (Number(mo) || 0) * 2592e3 + (Number(d) || 0) * 86400 + (Number(h) || 0) * 3600 + (Number(mi) || 0) * 60 + (Number(s) || 0);
  }
  __name(parseDuration, "parseDuration");
  function resolve2(uri, base) {
    if (!uri) return base;
    try {
      return new URL(uri, base).toString();
    } catch {
      return uri;
    }
  }
  __name(resolve2, "resolve");
  function expandTemplate(template, vars) {
    return template.replace(/\$(\w+)(?:%0(\d+)d)?\$|\$\$/g, (match, name, pad) => {
      if (match === "$$") return "$";
      const value = vars[name];
      if (value === void 0) return match;
      const str = String(value);
      return pad ? str.padStart(Number(pad), "0") : str;
    });
  }
  __name(expandTemplate, "expandTemplate");
  function parseMpd(xmlText, baseUrl) {
    const doc = new DOMParser().parseFromString(xmlText, "application/xml");
    if (doc.querySelector("parsererror")) throw new Error("Malformed MPD");
    const mpd = doc.documentElement;
    if (mpd.localName !== "MPD") throw new Error("Not an MPD document");
    const mpdBase = resolve2(firstChild(mpd, "BaseURL")?.textContent?.trim(), baseUrl);
    const totalDuration = parseDuration(attr(mpd, "mediaPresentationDuration"));
    const representations = [];
    let isProtected = false;
    for (const period of children(mpd, "Period")) {
      const periodBase = resolve2(firstChild(period, "BaseURL")?.textContent?.trim(), mpdBase);
      for (const set2 of children(period, "AdaptationSet")) {
        if (children(set2, "ContentProtection").length) isProtected = true;
        const setBase = resolve2(firstChild(set2, "BaseURL")?.textContent?.trim(), periodBase);
        const setMime = attr(set2, "mimeType");
        const setTemplate = firstChild(set2, "SegmentTemplate");
        for (const rep of children(set2, "Representation")) {
          if (children(rep, "ContentProtection").length) isProtected = true;
          const repBase = resolve2(firstChild(rep, "BaseURL")?.textContent?.trim(), setBase);
          const mime = attr(rep, "mimeType", setMime);
          const contentType = attr(set2, "contentType") || mime.split("/")[0];
          const entry = {
            id: attr(rep, "id") || String(representations.length),
            type: contentType === "audio" ? "audio" : contentType === "text" ? "text" : "video",
            mime,
            codecs: attr(rep, "codecs", attr(set2, "codecs")),
            bandwidth: num(rep, "bandwidth"),
            width: num(rep, "width", num(set2, "width")),
            height: num(rep, "height", num(set2, "height")),
            frameRate: attr(rep, "frameRate", attr(set2, "frameRate")),
            audioSamplingRate: num(rep, "audioSamplingRate"),
            lang: attr(set2, "lang"),
            duration: totalDuration,
            baseUrl: repBase,
            initSegment: null,
            segments: null,
            /** Set when the representation is one plain file we can range-read. */
            directUrl: null
          };
          const template = firstChild(rep, "SegmentTemplate") || setTemplate;
          const list = firstChild(rep, "SegmentList");
          const segBase = firstChild(rep, "SegmentBase");
          if (template) {
            applyTemplate(entry, template, repBase, totalDuration);
          } else if (list) {
            applyList(entry, list, repBase);
          } else {
            entry.directUrl = repBase;
            if (segBase) {
              const range = attr(segBase, "indexRange");
              entry.indexRange = range || null;
            }
          }
          representations.push(entry);
        }
      }
    }
    representations.sort((a, b) => b.height - a.height || b.bandwidth - a.bandwidth);
    return { duration: totalDuration, representations, protected: isProtected };
  }
  __name(parseMpd, "parseMpd");
  function applyTemplate(entry, template, base, totalDuration) {
    const vars = { RepresentationID: entry.id, Bandwidth: entry.bandwidth };
    const initTpl = attr(template, "initialization");
    const mediaTpl = attr(template, "media");
    const timescale = num(template, "timescale", 1) || 1;
    const startNumber = num(template, "startNumber", 1);
    if (initTpl) {
      entry.initSegment = { url: resolve2(expandTemplate(initTpl, vars), base), byteRange: null };
    }
    if (!mediaTpl) return;
    const timeline = firstChild(template, "SegmentTimeline");
    const segments = [];
    if (timeline) {
      let time = 0;
      let number = startNumber;
      for (const s of children(timeline, "S")) {
        const t2 = attr(s, "t");
        if (t2 !== "") time = Number(t2);
        const d = num(s, "d");
        const repeat = num(s, "r", 0);
        for (let i = 0; i <= repeat; i++) {
          segments.push({
            url: resolve2(expandTemplate(mediaTpl, { ...vars, Number: number, Time: time }), base),
            duration: d / timescale,
            sequence: number
          });
          time += d;
          number++;
        }
      }
    } else {
      const segDuration = num(template, "duration") / timescale;
      if (segDuration > 0 && totalDuration > 0) {
        const count = Math.ceil(totalDuration / segDuration);
        for (let i = 0; i < count; i++) {
          const number = startNumber + i;
          segments.push({
            url: resolve2(
              expandTemplate(mediaTpl, {
                ...vars,
                Number: number,
                Time: Math.round(i * segDuration * timescale)
              }),
              base
            ),
            duration: segDuration,
            sequence: number
          });
        }
      }
    }
    entry.segments = segments;
  }
  __name(applyTemplate, "applyTemplate");
  function applyList(entry, list, base) {
    const init = firstChild(list, "Initialization");
    if (init) {
      entry.initSegment = { url: resolve2(attr(init, "sourceURL"), base), byteRange: null };
    }
    const timescale = num(list, "timescale", 1) || 1;
    const segDuration = num(list, "duration") / timescale;
    entry.segments = children(list, "SegmentURL").map((s, i) => ({
      url: resolve2(attr(s, "media"), base),
      duration: segDuration,
      sequence: i
    }));
  }
  __name(applyList, "applyList");

  // src/media/filename.js
  var ILLEGAL = /[\u0000-\u001f\u007f<>:"/\\|?*]/g;
  var INVISIBLE = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g;
  var RESERVED = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
  var MAX_STEM = 120;
  var MAX_FIELD = 48;
  var URL_LIKE = /\b(?:https?:\/\/|www\.)\S+/gi;
  function cleanField(value) {
    const text = String(value ?? "").replace(URL_LIKE, " ").replace(/\s+/g, " ").trim();
    if (!text) return "";
    const points = [...text];
    if (points.length <= MAX_FIELD) return text;
    const cut = points.slice(0, MAX_FIELD).join("");
    const lastSpace = cut.lastIndexOf(" ");
    return (lastSpace > MAX_FIELD * 0.6 ? cut.slice(0, lastSpace) : cut).trim();
  }
  __name(cleanField, "cleanField");
  function sanitise(input, fallback = "media") {
    const cleaned = String(input ?? "").replace(INVISIBLE, "").replace(ILLEGAL, " ").replace(/[.\s]+$/g, "").replace(/^[.\s]+/g, "").replace(/\s+/g, " ").trim();
    if (!cleaned) return fallback;
    if (RESERVED.test(cleaned)) return `_${cleaned}`;
    const points = [...cleaned];
    return points.length > MAX_STEM ? points.slice(0, MAX_STEM).join("").trim() : cleaned;
  }
  __name(sanitise, "sanitise");
  function applyTemplate2(template, fields) {
    const filled = template.replace(/\{(\w+)\}/g, (_, key) => {
      const value = cleanField(fields[key]);
      return value ? sanitise(value, "") : "";
    });
    return filled.replace(/[-_\s]{2,}/g, "-").replace(/^[-_\s]+|[-_\s]+$/g, "").trim() || sanitise(fields.title || fields.id, "media");
  }
  __name(applyTemplate2, "applyTemplate");
  function buildFilename(template, fields, extension) {
    const stem = applyTemplate2(template, fields);
    const ext = String(extension || "bin").replace(/^\./, "").toLowerCase();
    return `${stem}.${ext}`;
  }
  __name(buildFilename, "buildFilename");
  var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
    "mp4",
    "m4v",
    "m4a",
    "m4s",
    "webm",
    "mov",
    "mkv",
    "ts",
    "mpd",
    "m3u8",
    "mp3",
    "aac",
    "opus",
    "ogg",
    "oga",
    "flac",
    "wav",
    "jpg",
    "jpeg",
    "png",
    "gif",
    "webp",
    "avif",
    "heic",
    "bmp",
    "vtt",
    "srt"
  ]);
  function guessExtension(url, mime = "") {
    const fromUrl = /\.([a-z0-9]{2,5})(?:\?|#|$)/i.exec(String(url).split("?")[0]);
    if (fromUrl) {
      const ext = fromUrl[1].toLowerCase();
      if (KNOWN_EXTENSIONS.has(ext)) return ext;
    }
    const map = {
      "video/mp4": "mp4",
      "audio/mp4": "m4a",
      "video/webm": "webm",
      "audio/webm": "webm",
      "video/mp2t": "ts",
      "audio/mpeg": "mp3",
      "audio/aac": "aac",
      "image/jpeg": "jpg",
      "image/png": "png",
      "image/webp": "webp",
      "image/gif": "gif",
      "image/avif": "avif"
    };
    const base = String(mime).split(";")[0].trim().toLowerCase();
    return map[base] || (base.startsWith("audio/") ? "m4a" : base.startsWith("image/") ? "jpg" : "mp4");
  }
  __name(guessExtension, "guessExtension");
  function today() {
    const now = /* @__PURE__ */ new Date();
    const pad = /* @__PURE__ */ __name((n) => String(n).padStart(2, "0"), "pad");
    return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
  }
  __name(today, "today");

  // src/media/mp4/merge.js
  init_net();

  // src/media/mp4/boxes.js
  var CONTAINERS = /* @__PURE__ */ new Set([
    "moov",
    "trak",
    "edts",
    "mdia",
    "minf",
    "dinf",
    "stbl",
    "mvex",
    "moof",
    "traf",
    "udta"
  ]);
  var HEADER_SIZE = 8;
  function readHeader(bytes, offset) {
    if (offset + 8 > bytes.length) return null;
    const view2 = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
    let size = view2.getUint32(offset);
    const type = String.fromCharCode(
      bytes[offset + 4],
      bytes[offset + 5],
      bytes[offset + 6],
      bytes[offset + 7]
    );
    let headerSize = 8;
    if (size === 1) {
      if (offset + 16 > bytes.length) return null;
      const hi = view2.getUint32(offset + 8);
      const lo = view2.getUint32(offset + 12);
      size = hi * 2 ** 32 + lo;
      headerSize = 16;
    } else if (size === 0) {
      size = bytes.length - offset;
    }
    if (size < headerSize) return null;
    return { type, size, headerSize };
  }
  __name(readHeader, "readHeader");
  function walk(bytes, start2, end, visit) {
    let offset = start2;
    while (offset + 8 <= end) {
      const header = readHeader(bytes, offset);
      if (!header) break;
      const boxEnd = Math.min(offset + header.size, end);
      if (visit({ ...header, start: offset, end: boxEnd, dataStart: offset + header.headerSize }) === false) {
        return;
      }
      if (header.size <= 0) break;
      offset = boxEnd;
    }
  }
  __name(walk, "walk");
  function buildTree(bytes, start2, end) {
    const nodes = [];
    walk(bytes, start2, end, (box) => {
      if (CONTAINERS.has(box.type)) {
        nodes.push({ type: box.type, data: null, children: buildTree(bytes, box.dataStart, box.end) });
      } else {
        nodes.push({ type: box.type, data: bytes.subarray(box.dataStart, box.end), children: null });
      }
    });
    return nodes;
  }
  __name(buildTree, "buildTree");
  function sizeOf(node) {
    if (node.children) {
      let total = HEADER_SIZE;
      for (const child of node.children) total += sizeOf(child);
      return total;
    }
    return HEADER_SIZE + node.data.length;
  }
  __name(sizeOf, "sizeOf");
  function writeNodes(nodes, target, offset) {
    const view2 = new DataView(target.buffer, target.byteOffset, target.byteLength);
    for (const node of nodes) {
      const size = sizeOf(node);
      view2.setUint32(offset, size);
      for (let i = 0; i < 4; i++) target[offset + 4 + i] = node.type.charCodeAt(i);
      if (node.children) {
        writeNodes(node.children, target, offset + HEADER_SIZE);
      } else {
        target.set(node.data, offset + HEADER_SIZE);
      }
      offset += size;
    }
    return offset;
  }
  __name(writeNodes, "writeNodes");
  function find(nodes, type) {
    return nodes?.find((n) => n.type === type) ?? null;
  }
  __name(find, "find");
  function findPath(nodes, ...path) {
    let current = nodes;
    let node = null;
    for (const type of path) {
      node = find(current, type);
      if (!node) return null;
      current = node.children;
    }
    return node;
  }
  __name(findPath, "findPath");
  function findDeep(nodes, type) {
    for (const node of nodes ?? []) {
      if (node.type === type) return node;
      if (node.children) {
        const hit = findDeep(node.children, type);
        if (hit) return hit;
      }
    }
    return null;
  }
  __name(findDeep, "findDeep");
  function collectDeep(nodes, type, out = []) {
    for (const node of nodes ?? []) {
      if (node.type === type) out.push(node);
      if (node.children) collectDeep(node.children, type, out);
    }
    return out;
  }
  __name(collectDeep, "collectDeep");
  function containsFourCC(data, code) {
    const a = code.charCodeAt(0);
    const b = code.charCodeAt(1);
    const c = code.charCodeAt(2);
    const d = code.charCodeAt(3);
    for (let i = 0; i + 3 < data.length; i++) {
      if (data[i] === a && data[i + 1] === b && data[i + 2] === c && data[i + 3] === d) return true;
    }
    return false;
  }
  __name(containsFourCC, "containsFourCC");
  var PROTECTION_MARKERS = ["sinf", "encv", "enca", "encs"];
  function isProtectedMoov(nodes) {
    if (findDeep(nodes, "pssh")) return true;
    for (const stsd of collectDeep(nodes, "stsd")) {
      if (!stsd.data) continue;
      for (const marker of PROTECTION_MARKERS) {
        if (containsFourCC(stsd.data, marker)) return true;
      }
    }
    return false;
  }
  __name(isProtectedMoov, "isProtectedMoov");
  function leaf(type, data) {
    return { type, data, children: null };
  }
  __name(leaf, "leaf");
  function container(type, children2) {
    return { type, data: null, children: children2 };
  }
  __name(container, "container");
  function ftypPayload(major = "isom", minor = 512, compatible = ["isom", "iso2", "avc1", "mp41"]) {
    const out = new Uint8Array(8 + compatible.length * 4);
    const view2 = new DataView(out.buffer);
    writeType(out, 0, major);
    view2.setUint32(4, minor);
    compatible.forEach((brand, i) => writeType(out, 8 + i * 4, brand));
    return out;
  }
  __name(ftypPayload, "ftypPayload");
  function writeType(target, offset, type) {
    for (let i = 0; i < 4; i++) target[offset + i] = type.charCodeAt(i);
  }
  __name(writeType, "writeType");
  function readU64(view2, offset) {
    return view2.getUint32(offset) * 2 ** 32 + view2.getUint32(offset + 4);
  }
  __name(readU64, "readU64");
  function writeU64(view2, offset, value) {
    view2.setUint32(offset, Math.floor(value / 2 ** 32));
    view2.setUint32(offset + 4, value >>> 0);
  }
  __name(writeU64, "writeU64");
  var dataView = /* @__PURE__ */ __name((node) => new DataView(node.data.buffer, node.data.byteOffset, node.data.byteLength), "dataView");
  function readMvhd(node) {
    const view2 = dataView(node);
    const version = node.data[0];
    return version === 1 ? { version, timescale: view2.getUint32(20), duration: readU64(view2, 24), nextTrackId: view2.getUint32(node.data.length - 4) } : { version, timescale: view2.getUint32(12), duration: view2.getUint32(16), nextTrackId: view2.getUint32(node.data.length - 4) };
  }
  __name(readMvhd, "readMvhd");
  function writeMvhd(node, { duration, nextTrackId }) {
    const view2 = dataView(node);
    const version = node.data[0];
    if (duration !== void 0) {
      if (version === 1) writeU64(view2, 24, duration);
      else view2.setUint32(16, Math.min(duration, 4294967295));
    }
    if (nextTrackId !== void 0) view2.setUint32(node.data.length - 4, nextTrackId);
  }
  __name(writeMvhd, "writeMvhd");
  function readTkhd(node) {
    const view2 = dataView(node);
    const version = node.data[0];
    return version === 1 ? { version, trackId: view2.getUint32(20), duration: readU64(view2, 28) } : { version, trackId: view2.getUint32(12), duration: view2.getUint32(20) };
  }
  __name(readTkhd, "readTkhd");
  function writeTkhd(node, { trackId, duration }) {
    const view2 = dataView(node);
    const version = node.data[0];
    if (version === 1) {
      if (trackId !== void 0) view2.setUint32(20, trackId);
      if (duration !== void 0) writeU64(view2, 28, duration);
    } else {
      if (trackId !== void 0) view2.setUint32(12, trackId);
      if (duration !== void 0) view2.setUint32(20, Math.min(duration, 4294967295));
    }
  }
  __name(writeTkhd, "writeTkhd");
  function handlerOf(trak) {
    const hdlr = findPath(trak.children, "mdia", "hdlr");
    if (!hdlr) return "";
    return String.fromCharCode(hdlr.data[8], hdlr.data[9], hdlr.data[10], hdlr.data[11]);
  }
  __name(handlerOf, "handlerOf");
  function rescaleElst(trak, factor) {
    const elst = findPath(trak.children, "edts", "elst");
    if (!elst || factor === 1) return;
    const view2 = dataView(elst);
    const version = elst.data[0];
    const count = view2.getUint32(4);
    const stride = version === 1 ? 20 : 12;
    for (let i = 0; i < count; i++) {
      const at = 8 + i * stride;
      if (at + stride > elst.data.length) break;
      if (version === 1) {
        writeU64(view2, at, Math.round(readU64(view2, at) * factor));
      } else {
        view2.setUint32(at, Math.round(view2.getUint32(at) * factor));
      }
    }
  }
  __name(rescaleElst, "rescaleElst");

  // src/media/mp4/merge.js
  var _MergeUnsupported = class _MergeUnsupported extends Error {
    constructor(reason) {
      super(reason);
      this.name = "MergeUnsupported";
    }
  };
  __name(_MergeUnsupported, "MergeUnsupported");
  var MergeUnsupported = _MergeUnsupported;
  var SCAN_WINDOW = 64 * 1024;
  async function indexRemoteMp4(url, size, opts = {}) {
    if (!size) throw new MergeUnsupported("Server did not report a content length");
    const boxes = [];
    let bufferStart = 0;
    let buffer = await readRange(url, 0, Math.min(SCAN_WINDOW, size) - 1, opts);
    let offset = 0;
    while (offset + 8 <= size) {
      if (offset < bufferStart || offset + 16 > bufferStart + buffer.length) {
        bufferStart = offset;
        buffer = await readRange(url, offset, Math.min(offset + SCAN_WINDOW, size) - 1, opts);
      }
      const header = readHeader(buffer, offset - bufferStart);
      if (!header) break;
      boxes.push({
        type: header.type,
        start: offset,
        dataStart: offset + header.headerSize,
        end: offset + header.size
      });
      if (header.size <= 0) break;
      offset += header.size;
    }
    const { moov, mdat } = selectBoxes(boxes);
    let moovBytes = null;
    if (moov.start >= bufferStart && moov.end <= bufferStart + buffer.length) {
      moovBytes = buffer.subarray(moov.start - bufferStart, moov.end - bufferStart);
    } else {
      moovBytes = await readRange(url, moov.start, moov.end - 1, opts);
    }
    return { url, size, moov, mdat, moovBytes };
  }
  __name(indexRemoteMp4, "indexRemoteMp4");
  function selectBoxes(boxes) {
    const moov = boxes.find((b) => b.type === "moov");
    const mdats = boxes.filter((b) => b.type === "mdat");
    if (!moov) throw new MergeUnsupported("No moov box: not a progressive MP4");
    if (mdats.length === 0) throw new MergeUnsupported("No mdat box");
    if (mdats.length > 1) {
      throw new MergeUnsupported("Multiple mdat boxes are not supported by the fast merge path");
    }
    return { moov, mdat: mdats[0] };
  }
  __name(selectBoxes, "selectBoxes");
  function cloneNode(node) {
    return node.children ? container(node.type, node.children.map(cloneNode)) : leaf(node.type, new Uint8Array(node.data));
  }
  __name(cloneNode, "cloneNode");
  function normaliseChunkOffsets(trak) {
    const stbl = findPath(trak.children, "mdia", "minf", "stbl");
    if (!stbl) throw new MergeUnsupported("Track has no sample table");
    const existing = find(stbl.children, "co64");
    if (existing) {
      const view2 = new DataView(existing.data.buffer, existing.data.byteOffset, existing.data.byteLength);
      const count2 = view2.getUint32(4);
      const offsets2 = new Array(count2);
      for (let i = 0; i < count2; i++) {
        offsets2[i] = view2.getUint32(8 + i * 8) * 2 ** 32 + view2.getUint32(12 + i * 8);
      }
      return { node: existing, offsets: offsets2 };
    }
    const stco = find(stbl.children, "stco");
    if (!stco) throw new MergeUnsupported("Track has no chunk offset table");
    const oldView = new DataView(stco.data.buffer, stco.data.byteOffset, stco.data.byteLength);
    const count = oldView.getUint32(4);
    const offsets = new Array(count);
    for (let i = 0; i < count; i++) offsets[i] = oldView.getUint32(8 + i * 4);
    const data = new Uint8Array(8 + count * 8);
    new DataView(data.buffer).setUint32(4, count);
    const co64 = leaf("co64", data);
    stbl.children[stbl.children.indexOf(stco)] = co64;
    return { node: co64, offsets };
  }
  __name(normaliseChunkOffsets, "normaliseChunkOffsets");
  function patchChunkOffsets(table, delta) {
    const view2 = new DataView(table.node.data.buffer, table.node.data.byteOffset, table.node.data.byteLength);
    for (let i = 0; i < table.offsets.length; i++) {
      writeU64(view2, 8 + i * 8, table.offsets[i] + delta);
    }
  }
  __name(patchChunkOffsets, "patchChunkOffsets");
  function assertNotProtected(moovNodes, label) {
    if (isProtectedMoov(moovNodes)) {
      throw new MergeUnsupported(
        `${label} is protected with Common Encryption. Open Media Grabber does not circumvent DRM.`
      );
    }
  }
  __name(assertNotProtected, "assertNotProtected");
  function assertInsideMdat(offsets, mdat, label) {
    for (const offset of offsets) {
      if (offset < mdat.dataStart || offset >= mdat.end) {
        throw new MergeUnsupported(`${label} track has sample data outside its mdat box`);
      }
    }
  }
  __name(assertInsideMdat, "assertInsideMdat");
  function pickTrack(moovNodes, handler) {
    for (const node of moovNodes) {
      if (node.type === "trak" && handlerOf(node) === handler) return node;
    }
    return null;
  }
  __name(pickTrack, "pickTrack");
  function planMerge(video, audio) {
    const videoMoov = buildTree(video.moovBytes, 8, video.moovBytes.length);
    const audioMoov = buildTree(audio.moovBytes, 8, audio.moovBytes.length);
    if (find(videoMoov, "mvex") || find(audioMoov, "mvex")) {
      throw new MergeUnsupported("Fragmented MP4 inputs are handled by the segment path, not the merge path");
    }
    assertNotProtected(videoMoov, "Video file");
    assertNotProtected(audioMoov, "Audio file");
    const sourceVideoTrak = pickTrack(videoMoov, "vide");
    const sourceAudioTrak = pickTrack(audioMoov, "soun");
    if (!sourceVideoTrak) throw new MergeUnsupported("No video track in the video file");
    if (!sourceAudioTrak) throw new MergeUnsupported("No audio track in the audio file");
    const videoTrak = cloneNode(sourceVideoTrak);
    const audioTrak = cloneNode(sourceAudioTrak);
    const sourceMvhd = find(videoMoov, "mvhd");
    if (!sourceMvhd) throw new MergeUnsupported("Video file has no movie header");
    const mvhd = cloneNode(sourceMvhd);
    const videoMvhd = readMvhd(sourceMvhd);
    const audioMvhdNode = find(audioMoov, "mvhd");
    const audioMvhd = audioMvhdNode ? readMvhd(audioMvhdNode) : videoMvhd;
    const scale = audioMvhd.timescale ? videoMvhd.timescale / audioMvhd.timescale : 1;
    const videoTkhdNode = find(videoTrak.children, "tkhd");
    if (!videoTkhdNode) throw new MergeUnsupported("Video track has no track header");
    writeTkhd(videoTkhdNode, { trackId: 1 });
    const audioTkhdNode = find(audioTrak.children, "tkhd");
    if (!audioTkhdNode) throw new MergeUnsupported("Audio track has no track header");
    const audioTkhd = readTkhd(audioTkhdNode);
    writeTkhd(audioTkhdNode, { trackId: 2, duration: Math.round(audioTkhd.duration * scale) });
    rescaleElst(audioTrak, scale);
    const videoTkhd = readTkhd(videoTkhdNode);
    writeMvhd(mvhd, {
      duration: Math.max(videoTkhd.duration, Math.round(audioTkhd.duration * scale), videoMvhd.duration),
      nextTrackId: 3
    });
    const videoOffsets = normaliseChunkOffsets(videoTrak);
    const audioOffsets = normaliseChunkOffsets(audioTrak);
    assertInsideMdat(videoOffsets.offsets, video.mdat, "Video");
    assertInsideMdat(audioOffsets.offsets, audio.mdat, "Audio");
    const moov = container("moov", [mvhd, videoTrak, audioTrak]);
    const ftyp = leaf("ftyp", ftypPayload());
    const videoPayloadLength = video.mdat.end - video.mdat.dataStart;
    const audioPayloadLength = audio.mdat.end - audio.mdat.dataStart;
    const mdatPayload = videoPayloadLength + audioPayloadLength;
    const ftypSize = sizeOf(ftyp);
    const moovSize = sizeOf(moov);
    const useLargeMdat = mdatPayload + 8 > 4294967295;
    const mdatHeaderSize = useLargeMdat ? 16 : 8;
    const videoDataStart = ftypSize + moovSize + mdatHeaderSize;
    const audioDataStart = videoDataStart + videoPayloadLength;
    patchChunkOffsets(videoOffsets, videoDataStart - video.mdat.dataStart);
    patchChunkOffsets(audioOffsets, audioDataStart - audio.mdat.dataStart);
    const header = new Uint8Array(videoDataStart);
    const cursor = writeNodes([ftyp, moov], header, 0);
    const view2 = new DataView(header.buffer);
    if (useLargeMdat) {
      view2.setUint32(cursor, 1);
      header.set([109, 100, 97, 116], cursor + 4);
      writeU64(view2, cursor + 8, mdatPayload + 16);
    } else {
      view2.setUint32(cursor, mdatPayload + 8);
      header.set([109, 100, 97, 116], cursor + 4);
    }
    return {
      header,
      parts: [
        {
          url: video.url,
          start: video.mdat.dataStart,
          end: video.mdat.end - 1,
          length: videoPayloadLength
        },
        {
          url: audio.url,
          start: audio.mdat.dataStart,
          end: audio.mdat.end - 1,
          length: audioPayloadLength
        }
      ],
      totalSize: videoDataStart + mdatPayload
    };
  }
  __name(planMerge, "planMerge");

  // src/media/writer.js
  init_env();
  init_log();
  var MIME_BY_EXT = {
    mp4: "video/mp4",
    m4a: "audio/mp4",
    m4v: "video/mp4",
    webm: "video/webm",
    ts: "video/mp2t",
    mp3: "audio/mpeg",
    aac: "audio/aac",
    opus: "audio/ogg",
    jpg: "image/jpeg",
    jpeg: "image/jpeg",
    png: "image/png",
    webp: "image/webp",
    gif: "image/gif"
  };
  function mimeForExtension(ext) {
    return MIME_BY_EXT[String(ext).toLowerCase()] || "application/octet-stream";
  }
  __name(mimeForExtension, "mimeForExtension");
  function canHandOff(url) {
    if (!gm.download || !/^https?:/i.test(url)) return false;
    if (scriptInfo.downloadMode === "browser") return true;
    try {
      return new URL(url, location.href).origin === location.origin;
    } catch {
      return false;
    }
  }
  __name(canHandOff, "canHandOff");
  function handoff(url, filename) {
    if (!canHandOff(url)) return false;
    try {
      gm.download({
        url,
        name: filename,
        onerror: /* @__PURE__ */ __name((err) => log.warn("GM_download failed", err), "onerror")
      });
      return true;
    } catch (err) {
      log.warn("GM_download threw", err);
      return false;
    }
  }
  __name(handoff, "handoff");
  async function pickSaveLocation(filename, extension) {
    if (!caps.fileSystemAccess) return null;
    try {
      return await globalThis.showSaveFilePicker({
        suggestedName: filename,
        types: [
          {
            description: extension.toUpperCase(),
            accept: { [mimeForExtension(extension)]: [`.${extension}`] }
          }
        ]
      });
    } catch (err) {
      if (err?.name === "AbortError") return null;
      log.warn("save picker unavailable", err);
      return null;
    }
  }
  __name(pickSaveLocation, "pickSaveLocation");
  async function createSink({ filename, extension, handle = null, expectedSize = 0 }) {
    if (handle) {
      const writable = await handle.createWritable();
      return {
        strategy: "disk",
        async write(chunk) {
          await writable.write(chunk);
        },
        async close() {
          await writable.close();
        },
        async abort(reason) {
          try {
            await writable.abort(reason);
          } catch {
          }
        }
      };
    }
    const limit = get("maxBufferedBytes");
    if (expectedSize && expectedSize > limit) {
      throw new Error(
        `This download is ${formatBytes(expectedSize)} and would have to be buffered in memory. Enable "ask where to save" (Chromium) to stream it to disk, or raise the buffer limit.`
      );
    }
    const chunks = [];
    let total = 0;
    return {
      strategy: "memory",
      async write(chunk) {
        total += chunk.length;
        if (total > limit) {
          chunks.length = 0;
          throw new Error(
            `Exceeded the ${formatBytes(limit)} in-memory buffer limit. Streaming to disk is required for a file this large.`
          );
        }
        chunks.push(chunk);
      },
      async close() {
        const blob = new Blob(chunks, { type: mimeForExtension(extension) });
        chunks.length = 0;
        saveBlob(blob, filename);
      },
      async abort() {
        chunks.length = 0;
      }
    };
  }
  __name(createSink, "createSink");
  function saveBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const anchor = document.createElement("a");
    anchor.href = url;
    anchor.download = filename;
    anchor.rel = "noopener";
    anchor.style.display = "none";
    document.body.appendChild(anchor);
    anchor.click();
    setTimeout(() => {
      anchor.remove();
      URL.revokeObjectURL(url);
    }, 6e4);
  }
  __name(saveBlob, "saveBlob");
  async function pump(stream, sink, { onProgress, signal, chunkSize } = {}) {
    const target = chunkSize ?? get("chunkSize");
    const reader = stream.getReader();
    let pending = [];
    let pendingBytes = 0;
    let written = 0;
    const flush = /* @__PURE__ */ __name(async () => {
      if (!pendingBytes) return;
      const batch = pending.length === 1 ? pending[0] : concat(pending, pendingBytes);
      pending = [];
      pendingBytes = 0;
      await sink.write(batch);
    }, "flush");
    try {
      for (; ; ) {
        if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
        const { done, value } = await reader.read();
        if (done) break;
        if (!value?.length) continue;
        const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
        pending.push(chunk);
        pendingBytes += chunk.length;
        written += chunk.length;
        if (pendingBytes >= target) await flush();
        onProgress?.(written);
      }
      await flush();
    } finally {
      reader.releaseLock?.();
    }
    return written;
  }
  __name(pump, "pump");
  function concat(chunks, totalLength) {
    const total = totalLength ?? chunks.reduce((sum, c) => sum + c.length, 0);
    const out = new Uint8Array(total);
    let offset = 0;
    for (const chunk of chunks) {
      out.set(chunk, offset);
      offset += chunk.length;
    }
    return out;
  }
  __name(concat, "concat");
  function formatBytes(bytes) {
    if (!bytes) return "—";
    const units = ["B", "kB", "MB", "GB", "TB"];
    let value = bytes;
    let unit = 0;
    while (value >= 1024 && unit < units.length - 1) {
      value /= 1024;
      unit++;
    }
    return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
  }
  __name(formatBytes, "formatBytes");

  // src/media/pipeline.js
  var _DrmRefused = class _DrmRefused extends Error {
    constructor() {
      super("This stream is DRM protected. Open Media Grabber does not circumvent DRM.");
      this.name = "DrmRefused";
    }
  };
  __name(_DrmRefused, "DrmRefused");
  var DrmRefused = _DrmRefused;
  var keyCache = /* @__PURE__ */ new Map();
  async function fetchKey(url, opts) {
    if (keyCache.has(url)) return keyCache.get(url);
    const promise = readRange(url, 0, 15, opts).then((bytes) => bytes.buffer.slice(0, 16));
    keyCache.set(url, promise);
    return promise;
  }
  __name(fetchKey, "fetchKey");
  async function fetchSegment(segment, opts) {
    return withRetry(
      async () => {
        let bytes;
        if (segment.byteRange) {
          const { offset, length } = segment.byteRange;
          bytes = await readRange(segment.url, offset, offset + length - 1, opts);
        } else {
          const stream = await openStream(segment.url, opts);
          bytes = await drain(stream);
        }
        const enc = segment.encryption;
        if (enc && enc.method === "AES-128") {
          const key = await fetchKey(enc.url, opts);
          bytes = await decryptSegment(bytes, key, enc.iv, segment.sequence);
        }
        return bytes;
      },
      { attempts: 3, signal: opts.signal }
    );
  }
  __name(fetchSegment, "fetchSegment");
  async function drain(stream) {
    const reader = stream.getReader();
    const chunks = [];
    let total = 0;
    for (; ; ) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
      total += value.length;
    }
    if (chunks.length === 1) return chunks[0];
    const out = new Uint8Array(total);
    let offset = 0;
    for (const chunk of chunks) {
      out.set(chunk, offset);
      offset += chunk.length;
    }
    return out;
  }
  __name(drain, "drain");
  function createSegmentStream({ initSegment, segments, concurrency, opts }) {
    let scheduled = 0;
    const inflight = [];
    let initPending = initSegment ? fetchSegment(initSegment, opts) : null;
    const schedule = /* @__PURE__ */ __name(() => {
      while (inflight.length < concurrency && scheduled < segments.length) {
        inflight.push(fetchSegment(segments[scheduled++], opts));
      }
    }, "schedule");
    return new ReadableStream({
      async pull(controller) {
        if (initPending) {
          const bytes = await initPending;
          initPending = null;
          controller.enqueue(bytes);
          return;
        }
        schedule();
        if (!inflight.length) {
          controller.close();
          return;
        }
        controller.enqueue(await inflight.shift());
        schedule();
      },
      cancel() {
        scheduled = segments.length;
        inflight.length = 0;
      }
    });
  }
  __name(createSegmentStream, "createSegmentStream");
  function createMergeStream(plan, opts) {
    const chunkSize = get("chunkSize");
    let partIndex = 0;
    let cursor = plan.parts[0]?.start ?? 0;
    let headerSent = false;
    return new ReadableStream({
      async pull(controller) {
        if (!headerSent) {
          headerSent = true;
          controller.enqueue(plan.header);
          return;
        }
        const part = plan.parts[partIndex];
        if (!part) {
          controller.close();
          return;
        }
        const end = Math.min(cursor + chunkSize - 1, part.end);
        const bytes = await withRetry(() => readRange(part.url, cursor, end, opts), {
          attempts: 3,
          signal: opts.signal
        });
        cursor = end + 1;
        if (cursor > part.end) {
          partIndex++;
          cursor = plan.parts[partIndex]?.start ?? 0;
        }
        controller.enqueue(bytes);
      }
    });
  }
  __name(createMergeStream, "createMergeStream");
  function nameFor(asset, extension) {
    return buildFilename(
      get("filenameTemplate"),
      {
        platform: asset.platform,
        author: asset.author,
        title: asset.title,
        id: asset.postId,
        quality: asset.height ? `${asset.height}p` : "",
        index: asset.index ? String(asset.index) : "",
        date: today()
      },
      extension
    );
  }
  __name(nameFor, "nameFor");
  async function planDownload(asset, { signal } = {}) {
    if (asset.drm) throw new DrmRefused();
    const opts = { signal, referer: asset.referer };
    const concurrency = Math.max(1, Math.min(8, get("segmentConcurrency")));
    if (asset.kind === "direct" || asset.kind === "image") {
      const extension = asset.extension || guessExtension(asset.url, asset.mime);
      const filename = nameFor(asset, extension);
      return {
        filename,
        extension,
        expectedSize: asset.size || 0,
        // A complete file at a plain URL is the one case where we can do nothing
        // at all and let the browser handle it. That is always the right answer.
        mode: "handoff",
        url: asset.url,
        async open(sig) {
          return openStream(asset.url, { ...opts, signal: sig });
        }
      };
    }
    if (asset.kind === "merge") {
      const [videoInfo, audioInfo] = await Promise.all([
        probe(asset.videoUrl, opts),
        probe(asset.audioUrl, opts)
      ]);
      const [videoIndex, audioIndex] = await Promise.all([
        indexRemoteMp4(asset.videoUrl, videoInfo.size, opts),
        indexRemoteMp4(asset.audioUrl, audioInfo.size, opts)
      ]);
      const plan = planMerge(videoIndex, audioIndex);
      const filename = nameFor(asset, "mp4");
      return {
        filename,
        extension: "mp4",
        expectedSize: plan.totalSize,
        mode: "stream",
        async open(sig) {
          return createMergeStream(plan, { ...opts, signal: sig });
        }
      };
    }
    if (asset.kind === "hls") {
      const { segments, initSegment, container: container2, encryption, totalBytesHint } = await resolveHls(
        asset,
        opts
      );
      if (encryption?.drm) throw new DrmRefused();
      const extension = container2 === "fmp4" ? "mp4" : "ts";
      return {
        filename: nameFor(asset, extension),
        extension,
        expectedSize: totalBytesHint,
        mode: "stream",
        async open(sig) {
          return createSegmentStream({
            initSegment,
            segments,
            concurrency,
            opts: { ...opts, signal: sig }
          });
        }
      };
    }
    if (asset.kind === "dash" || asset.kind === "dash-inline") {
      return planDash(asset, opts, concurrency);
    }
    throw new Error(`Unsupported asset kind: ${asset.kind}`);
  }
  __name(planDownload, "planDownload");
  async function resolveHls(asset, opts) {
    const { getText: getText2 } = await Promise.resolve().then(() => (init_net(), net_exports));
    let manifestUrl = asset.manifestUrl;
    let text = await getText2(manifestUrl, opts);
    if (isMasterPlaylist(text)) {
      const { variants } = parseMaster(text, manifestUrl);
      const chosen = pickVariant(variants, asset.height);
      if (!chosen) throw new Error("No playable variant in the HLS master playlist");
      manifestUrl = chosen.url;
      text = await getText2(manifestUrl, opts);
    }
    const media = parseMedia(text, manifestUrl);
    if (!media.segments.length) throw new Error("HLS playlist contains no segments");
    if (media.isLive) {
      log.info("playlist has no EXT-X-ENDLIST; capturing the currently listed window");
    }
    return { ...media, totalBytesHint: 0 };
  }
  __name(resolveHls, "resolveHls");
  function pickVariant(variants, preferredHeight) {
    if (!variants.length) return null;
    const preference = get("preferredQuality");
    if (preferredHeight) {
      const exact = variants.find((v) => v.height === preferredHeight);
      if (exact) return exact;
    }
    if (preference === "lowest") return variants[variants.length - 1];
    if (/^\d+$/.test(preference)) {
      const target = Number(preference);
      return variants.find((v) => v.height <= target) ?? variants[variants.length - 1];
    }
    return variants[0];
  }
  __name(pickVariant, "pickVariant");
  async function planDash(asset, opts, concurrency) {
    let text = asset.manifestXml;
    if (!text) {
      const { getText: getText2 } = await Promise.resolve().then(() => (init_net(), net_exports));
      text = await getText2(asset.manifestUrl, opts);
    }
    const { representations, protected: isProtected } = parseMpd(text, asset.manifestUrl);
    if (isProtected) throw new DrmRefused();
    const videos = representations.filter((r) => r.type === "video");
    const audios = representations.filter((r) => r.type === "audio");
    const video = videos[0];
    const audio = audios[0];
    if (!video) throw new Error("No video representation in the MPD");
    if (video.directUrl && audio?.directUrl) {
      return planDownload(
        {
          ...asset,
          kind: "merge",
          videoUrl: video.directUrl,
          audioUrl: audio.directUrl,
          height: video.height
        },
        opts
      );
    }
    if (video.directUrl && !audio) {
      return planDownload({ ...asset, kind: "direct", url: video.directUrl }, opts);
    }
    if (!video.segments?.length) throw new Error("MPD representation has no addressable segments");
    const extension = "mp4";
    return {
      filename: nameFor({ ...asset, height: video.height }, extension),
      extension,
      expectedSize: 0,
      mode: "stream",
      note: audio ? "video track only (segmented DASH audio cannot be merged losslessly)" : "",
      async open(sig) {
        return createSegmentStream({
          initSegment: video.initSegment,
          segments: video.segments,
          concurrency,
          opts: { ...opts, signal: sig }
        });
      }
    };
  }
  __name(planDash, "planDash");
  async function runPlan(plan, { handle = null, onProgress, signal } = {}) {
    if (plan.mode === "handoff" && !handle && handoff(plan.url, plan.filename)) {
      onProgress?.({ phase: "handed-off", filename: plan.filename });
      return { strategy: "handoff", filename: plan.filename, bytes: plan.expectedSize };
    }
    const sink = await createSink({
      filename: plan.filename,
      extension: plan.extension,
      handle,
      expectedSize: plan.expectedSize
    });
    const controller = new AbortController();
    const onAbort = /* @__PURE__ */ __name(() => controller.abort(), "onAbort");
    signal?.addEventListener("abort", onAbort, { once: true });
    try {
      const stream = await plan.open(controller.signal);
      const written = await pump(stream, sink, {
        signal: controller.signal,
        onProgress: /* @__PURE__ */ __name((bytes) => onProgress?.({
          phase: "downloading",
          bytes,
          total: plan.expectedSize,
          filename: plan.filename
        }), "onProgress")
      });
      await sink.close();
      onProgress?.({ phase: "done", bytes: written, total: written, filename: plan.filename });
      return { strategy: sink.strategy, filename: plan.filename, bytes: written };
    } catch (err) {
      await sink.abort(err);
      throw err;
    } finally {
      signal?.removeEventListener("abort", onAbort);
    }
  }
  __name(runPlan, "runPlan");

  // src/ui/i18n.js
  var STRINGS = {
    en: {
      title: "Media on this page",
      empty: "Nothing found yet. Start the video, or scroll to the post you want.",
      scan: "Scan again",
      deepScan: "Deep scan",
      settings: "Settings",
      close: "Close",
      download: "Download",
      saveAs: "Save as…",
      cancel: "Cancel",
      copyLink: "Copy link",
      copied: "Copied",
      preparing: "Preparing…",
      downloading: "Downloading",
      merging: "Merging tracks",
      done: "Saved",
      failed: "Failed",
      handedOff: "Handed to the browser",
      drmRefused: "DRM protected — not supported.",
      ofSize: "of",
      unknownSize: "unknown size",
      settingsTitle: "Settings",
      settingFilename: "Filename template",
      settingQuality: "Preferred quality",
      settingAskWhere: "Ask where to save",
      settingAskWhereHint: "Streams straight to disk. Chromium only.",
      settingDeep: "Inspect network responses",
      settingDeepHint: "Needed on most social sites.",
      settingConcurrency: "Parallel segment downloads",
      settingAutoMerge: "Merge separate video and audio automatically",
      settingLauncher: "Show the floating button",
      settingLocale: "Language",
      settingLog: "Console logging",
      reset: "Reset to defaults",
      qualityAuto: "Highest available",
      qualityLowest: "Smallest file",
      noBackend: "No servers, no tracking, no referral links."
    },
    it: {
      title: "Media in questa pagina",
      empty: "Ancora niente. Avvia il video o scorri fino al post che ti interessa.",
      scan: "Scansiona di nuovo",
      deepScan: "Scansione approfondita",
      settings: "Impostazioni",
      close: "Chiudi",
      download: "Scarica",
      saveAs: "Salva come…",
      cancel: "Annulla",
      copyLink: "Copia link",
      copied: "Copiato",
      preparing: "Preparazione…",
      downloading: "Download in corso",
      merging: "Unione tracce",
      done: "Salvato",
      failed: "Non riuscito",
      handedOff: "Passato al browser",
      drmRefused: "Protetto da DRM — non supportato.",
      ofSize: "di",
      unknownSize: "dimensione sconosciuta",
      settingsTitle: "Impostazioni",
      settingFilename: "Modello nome file",
      settingQuality: "Qualità preferita",
      settingAskWhere: "Chiedi dove salvare",
      settingAskWhereHint: "Scrive direttamente su disco. Solo Chromium.",
      settingDeep: "Ispeziona le risposte di rete",
      settingDeepHint: "Serve su quasi tutti i social.",
      settingConcurrency: "Download paralleli dei segmenti",
      settingAutoMerge: "Unisci automaticamente video e audio separati",
      settingLauncher: "Mostra il pulsante flottante",
      settingLocale: "Lingua",
      settingLog: "Log in console",
      reset: "Ripristina i valori predefiniti",
      qualityAuto: "La più alta disponibile",
      qualityLowest: "File più piccolo",
      noBackend: "Nessun server, nessun tracciamento, nessun link referral."
    },
    es: {
      title: "Medios en esta página",
      empty: "Aún no hay nada. Inicia el vídeo o desplázate hasta la publicación.",
      scan: "Volver a escanear",
      deepScan: "Escaneo profundo",
      settings: "Ajustes",
      close: "Cerrar",
      download: "Descargar",
      saveAs: "Guardar como…",
      cancel: "Cancelar",
      copyLink: "Copiar enlace",
      copied: "Copiado",
      preparing: "Preparando…",
      downloading: "Descargando",
      merging: "Uniendo pistas",
      done: "Guardado",
      failed: "Error",
      handedOff: "Enviado al navegador",
      drmRefused: "Protegido con DRM: no compatible.",
      ofSize: "de",
      unknownSize: "tamaño desconocido",
      noBackend: "Sin servidores, sin rastreo, sin enlaces de afiliados."
    },
    fr: {
      title: "Médias sur cette page",
      empty: "Rien pour le moment. Lancez la vidéo ou faites défiler jusqu’au post.",
      scan: "Analyser à nouveau",
      deepScan: "Analyse approfondie",
      settings: "Paramètres",
      close: "Fermer",
      download: "Télécharger",
      saveAs: "Enregistrer sous…",
      cancel: "Annuler",
      copyLink: "Copier le lien",
      copied: "Copié",
      preparing: "Préparation…",
      downloading: "Téléchargement",
      merging: "Fusion des pistes",
      done: "Enregistré",
      failed: "Échec",
      handedOff: "Transmis au navigateur",
      drmRefused: "Protégé par DRM — non pris en charge.",
      ofSize: "sur",
      unknownSize: "taille inconnue",
      noBackend: "Aucun serveur, aucun suivi, aucun lien d’affiliation."
    },
    de: {
      title: "Medien auf dieser Seite",
      empty: "Noch nichts gefunden. Starte das Video oder scrolle zum Beitrag.",
      scan: "Erneut scannen",
      deepScan: "Tiefenscan",
      settings: "Einstellungen",
      close: "Schließen",
      download: "Herunterladen",
      saveAs: "Speichern unter…",
      cancel: "Abbrechen",
      copyLink: "Link kopieren",
      copied: "Kopiert",
      preparing: "Wird vorbereitet…",
      downloading: "Wird heruntergeladen",
      merging: "Spuren werden zusammengeführt",
      done: "Gespeichert",
      failed: "Fehlgeschlagen",
      handedOff: "An den Browser übergeben",
      drmRefused: "DRM-geschützt — nicht unterstützt.",
      ofSize: "von",
      unknownSize: "unbekannte Größe",
      noBackend: "Keine Server, kein Tracking, keine Referral-Links."
    },
    pt: {
      title: "Mídia nesta página",
      empty: "Nada ainda. Inicie o vídeo ou role até a publicação.",
      scan: "Escanear novamente",
      deepScan: "Varredura profunda",
      settings: "Configurações",
      close: "Fechar",
      download: "Baixar",
      saveAs: "Salvar como…",
      cancel: "Cancelar",
      copyLink: "Copiar link",
      copied: "Copiado",
      preparing: "Preparando…",
      downloading: "Baixando",
      merging: "Unindo faixas",
      done: "Salvo",
      failed: "Falhou",
      handedOff: "Entregue ao navegador",
      drmRefused: "Protegido por DRM — sem suporte.",
      ofSize: "de",
      unknownSize: "tamanho desconhecido",
      noBackend: "Sem servidores, sem rastreamento, sem links de afiliados."
    },
    ru: {
      title: "Медиа на этой странице",
      empty: "Пока ничего нет. Запустите видео или прокрутите до нужного поста.",
      scan: "Сканировать снова",
      deepScan: "Глубокое сканирование",
      settings: "Настройки",
      close: "Закрыть",
      download: "Скачать",
      saveAs: "Сохранить как…",
      cancel: "Отмена",
      copyLink: "Копировать ссылку",
      copied: "Скопировано",
      preparing: "Подготовка…",
      downloading: "Загрузка",
      merging: "Объединение дорожек",
      done: "Сохранено",
      failed: "Ошибка",
      handedOff: "Передано браузеру",
      drmRefused: "Защищено DRM — не поддерживается.",
      ofSize: "из",
      unknownSize: "размер неизвестен",
      noBackend: "Без серверов, без слежки, без реферальных ссылок."
    },
    "zh-CN": {
      title: "本页媒体",
      empty: "尚未发现内容。请播放视频,或滚动到目标帖子。",
      scan: "重新扫描",
      deepScan: "深度扫描",
      settings: "设置",
      close: "关闭",
      download: "下载",
      saveAs: "另存为…",
      cancel: "取消",
      copyLink: "复制链接",
      copied: "已复制",
      preparing: "准备中…",
      downloading: "下载中",
      merging: "正在合并音视频",
      done: "已保存",
      failed: "失败",
      handedOff: "已交给浏览器",
      drmRefused: "受 DRM 保护,不支持。",
      ofSize: "/",
      unknownSize: "大小未知",
      noBackend: "无服务器、无追踪、无推广链接。"
    },
    ja: {
      title: "このページのメディア",
      empty: "まだ見つかりません。動画を再生するか、目的の投稿までスクロールしてください。",
      scan: "再スキャン",
      deepScan: "詳細スキャン",
      settings: "設定",
      close: "閉じる",
      download: "ダウンロード",
      saveAs: "名前を付けて保存…",
      cancel: "キャンセル",
      copyLink: "リンクをコピー",
      copied: "コピーしました",
      preparing: "準備中…",
      downloading: "ダウンロード中",
      merging: "トラックを結合中",
      done: "保存しました",
      failed: "失敗",
      handedOff: "ブラウザに引き渡しました",
      drmRefused: "DRM 保護のため非対応です。",
      ofSize: "/",
      unknownSize: "サイズ不明",
      noBackend: "サーバーなし、追跡なし、アフィリエイトリンクなし。"
    }
  };
  var AVAILABLE_LOCALES = Object.keys(STRINGS);
  var active = "en";
  function normalise(tag) {
    if (!tag) return "en";
    if (STRINGS[tag]) return tag;
    const base = tag.split("-")[0];
    if (STRINGS[base]) return base;
    if (base === "zh") return "zh-CN";
    return "en";
  }
  __name(normalise, "normalise");
  function setLocale(preference) {
    active = normalise(preference === "auto" || !preference ? navigator.language : preference);
    return active;
  }
  __name(setLocale, "setLocale");
  function t(key) {
    return STRINGS[active]?.[key] ?? STRINGS.en[key] ?? key;
  }
  __name(t, "t");

  // src/ui/styles.js
  var PANEL_CSS = `
:host {
  --surface: #ffffff;
  --surface-raised: #f5f5f8;
  --text: #1c1c22;          /* 15.4:1 */
  --text-muted: #63636f;    /* 6.0:1  */
  --line: #e6e6ec;
  --line-strong: #8b8b97;   /* 3.0:1 */
  --accent: #5b30c4;        /* 8.0:1 */
  --accent-text: #ffffff;
  --accent-quiet: #f2eeff;
  --ok: #17663f;            /* 5.8:1 */
  --danger: #b3261e;        /* 5.9:1 */
  --focus: #5b30c4;         /* 8.0:1 */
  --backdrop: rgba(20, 20, 26, 0.44);

  --radius: 0.75rem;
  --radius-sm: 0.375rem;

  --font: "Atkinson Hyperlegible Next", "Atkinson Hyperlegible",
    system-ui, -apple-system, "Segoe UI Variable Text", "Segoe UI",
    Roboto, "Inter", "Noto Sans", "Liberation Sans", Arial, sans-serif;
}

@media (prefers-color-scheme: dark) {
  :host {
    --surface: #1a1a1f;
    --surface-raised: #24242b;
    --text: #ededf2;          /* 14.4:1 */
    --text-muted: #a3a3b0;    /* 6.9:1  */
    --line: #2e2e37;
    --line-strong: #747482;   /* 3.4:1 */
    --accent: #bfa8ff;        /* 8.4:1 */
    --accent-text: #17171c;
    --accent-quiet: #262036;
    --ok: #56cc90;            /* 8.7:1 */
    --danger: #ff9f97;        /* 8.0:1 */
    --focus: #bfa8ff;         /* 8.4:1 */
    --backdrop: rgba(0, 0, 0, 0.62);
  }
}

@media (forced-colors: active) {
  .dialog, .toast { border: 1px solid CanvasText; }
  button { border: 1px solid ButtonText; }
}

*, *::before, *::after { box-sizing: border-box; }

/*
 * Typography goes on the shadow root's children, not on :host.
 *
 * The host carries an inline "all: initial" so no page style can leak in, and
 * an inline declaration outranks every rule in this sheet — :host included.
 * Declaring the font there had no effect at all and the panel rendered in the
 * browser's default serif.
 * (This sheet lives in a JS template literal, so no backticks in here.)
 */
.backdrop, .toast, .launcher {
  font-family: var(--font);
  /* 15px. The panel sat at 14px with 12px secondary text, which was smaller
     than the interface it floats over and unreadable at a glance. */
  font-size: 0.9375rem;
  font-weight: 400;
  line-height: 1.45;
  color: var(--text);
  -webkit-font-smoothing: antialiased;
}

/* ---------------------------------------------------------------- *
 * Dialog
 * ---------------------------------------------------------------- */

.backdrop {
  position: fixed;
  inset: 0;
  z-index: 2147483600;
  display: grid;
  place-items: center;
  padding: 1rem;
  background: var(--backdrop);
  animation: fade 120ms ease-out;
}

.dialog {
  display: flex;
  flex-direction: column;
  width: min(26rem, 100%);
  max-height: min(32rem, calc(100vh - 2rem));
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.24);
  overflow: hidden;
  animation: rise 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* The dialog takes focus on open so no control lights up; it is not a tab stop. */
.dialog:focus { outline: none; }

@keyframes fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes rise { from { opacity: 0; transform: translateY(0.5rem) } to { opacity: 1; transform: none } }

header {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.625rem 0.5rem 0.625rem 0.875rem;
  border-bottom: 1px solid var(--line);
}
header h2 {
  margin: 0;
  flex: 1;
  min-width: 0;
  font-size: 0.875rem;
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--text-muted);
}

/* ---------------------------------------------------------------- *
 * Controls
 * ---------------------------------------------------------------- */

button {
  font: inherit;
  color: inherit;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  min-height: 1.75rem;
  padding: 0.25rem 0.5rem;
  cursor: pointer;
  transition: background-color 100ms ease, color 100ms ease;
}
button:hover { background: var(--surface-raised); }
button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
button:disabled { opacity: 0.5; cursor: not-allowed; }

/* One action per row carries the accent, and only as text until you reach it. */
button.primary { color: var(--accent); font-weight: 500; }
button.primary:hover { background: var(--accent-quiet); }

/*
 * Icon buttons look like nothing until hovered or focused. 2.5.8 asks for a
 * 24px target, which the padding provides without drawing a box.
 */
button.icon {
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  display: inline-grid;
  place-items: center;
  color: var(--text-muted);
  font-size: 0.9375rem;
  line-height: 1;
}
button.icon:hover { color: var(--text); background: var(--surface-raised); }

.launcher {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  z-index: 2147483600;
  padding: 0.4375rem 0.875rem;
  border-radius: 999px;
  border: 1px solid var(--line-strong);
  background: var(--surface);
  color: var(--text);
  box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.18);
}
.launcher:hover { background: var(--surface-raised); }

/* ---------------------------------------------------------------- *
 * List
 * ---------------------------------------------------------------- */

.list { overflow-y: auto; overscroll-behavior: contain; flex: 1; }

.item {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.5rem 0.5rem 0.5rem 0.875rem;
}
.item + .item { border-top: 1px solid var(--line); }
.item .body { flex: 1; min-width: 0; }
.item .label {
  margin: 0;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.item .meta {
  margin: 0;
  font-size: 0.8125rem;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}
.item .actions { display: flex; align-items: center; gap: 0.125rem; flex-shrink: 0; }

.item.notice {
  display: block;
  padding: 0.75rem 0.875rem;
  background: var(--accent-quiet);
}
.item.notice .meta { white-space: normal; }

.empty {
  margin: 0;
  padding: 2.5rem 1.25rem;
  text-align: center;
  color: var(--text-muted);
}

/* ---------------------------------------------------------------- *
 * Progress
 * ---------------------------------------------------------------- */

.progress {
  height: 0.1875rem;
  margin-top: 0.375rem;
  background: var(--line);
  border-radius: 999px;
  overflow: hidden;
}
.progress > span {
  display: block;
  height: 100%;
  background: var(--accent);
  transition: width 160ms linear;
}
.progress.indeterminate > span { width: 35%; animation: slide 1.1s ease-in-out infinite; }
@keyframes slide { 0% { margin-left: -35% } 100% { margin-left: 100% } }

.state-done { color: var(--ok); }
.state-failed { color: var(--danger); }

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* ---------------------------------------------------------------- *
 * Settings
 * ---------------------------------------------------------------- */

/* Extra room at the end so the scroll boundary lands in whitespace rather
   than slicing the last label in half. */
.settings { padding: 0.875rem 0.875rem 1.5rem; display: grid; gap: 0.75rem; overflow-y: auto; }
.settings .field { display: grid; gap: 0.1875rem; }
.settings .field.inline {
  grid-template-columns: auto 1fr;
  align-items: center;
  column-gap: 0.5rem;
  row-gap: 0;
}
.settings .field.inline .hint { grid-column: 2; }
.settings label { font-size: 0.8125rem; font-weight: 500; }
.settings input[type="text"],
.settings input[type="number"],
.settings select {
  font: inherit;
  padding: 0.375rem 0.5rem;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--text);
  width: 100%;
  min-height: 1.75rem;
  font-size: 0.8125rem;
  /* Native controls default to the platform blue, which fought the accent. */
  accent-color: var(--accent);
}
.settings input:focus-visible, .settings select:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
.settings input[type="checkbox"] { width: 0.9375rem; height: 0.9375rem; margin: 0; accent-color: var(--accent); }
.settings .hint { margin: 0; color: var(--text-muted); font-size: 0.75rem; font-weight: 400; }

footer {
  display: flex;
  gap: 0.375rem;
  padding: 0.5rem 0.875rem;
  border-top: 1px solid var(--line);
  font-size: 0.75rem;
  color: var(--text-muted);
}
footer .spacer { flex: 1; }

/* ---------------------------------------------------------------- *
 * Toast — one line, so a download does not need the dialog at all
 * ---------------------------------------------------------------- */

.toast {
  position: fixed;
  left: 50%;
  bottom: 1.25rem;
  transform: translateX(-50%);
  z-index: 2147483600;
  display: flex;
  align-items: center;
  gap: 0.625rem;
  width: max-content;
  max-width: min(30rem, calc(100vw - 2rem));
  padding: 0.5rem 0.5rem 0.5rem 0.875rem;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 999px;
  box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.2);
  animation: rise 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.toast .body { min-width: 0; flex: 1; }
.toast .title {
  margin: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.toast .meta {
  margin: 0;
  font-size: 0.8125rem;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.toast .progress { margin-top: 0.25rem; }

@media (prefers-reduced-motion: reduce) {
  .backdrop, .dialog, .toast { animation: none; }
  .progress > span { transition: none; }
  .progress.indeterminate > span { animation: none; width: 100%; opacity: 0.5; }
  button { transition: none; }
}
`;

  // src/ui/panel.js
  var HOST_ID = "open-media-grabber-root";
  var mounted = null;
  var view = "closed";
  var scopedAssets = null;
  var inlineActive = false;
  var lastFocused = null;
  var jobs = /* @__PURE__ */ new Map();
  var toast = null;
  var refused = /* @__PURE__ */ new Set();
  var signatureOf = /* @__PURE__ */ __name((asset) => [asset.platform, asset.kind, asset.label, asset.height || 0].join("|"), "signatureOf");
  var awaitingDom = false;
  function canMount() {
    if (document.body) return true;
    if (!awaitingDom) {
      awaitingDom = true;
      const retry = /* @__PURE__ */ __name(() => {
        awaitingDom = false;
        render();
      }, "retry");
      if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", retry, { once: true });
      } else {
        queueMicrotask(retry);
      }
    }
    return false;
  }
  __name(canMount, "canMount");
  function mount() {
    if (mounted) return mounted;
    const host = document.createElement("div");
    host.id = HOST_ID;
    host.style.cssText = "all:initial;position:static;";
    const root = host.attachShadow({ mode: "open" });
    const style = document.createElement("style");
    style.textContent = PANEL_CSS;
    root.appendChild(style);
    document.body.appendChild(host);
    mounted = { host, root };
    return mounted;
  }
  __name(mount, "mount");
  function el(tag, props = {}, children2 = []) {
    const node = document.createElement(tag);
    for (const [key, value] of Object.entries(props)) {
      if (value === void 0 || value === null || value === false) continue;
      if (key === "class") node.className = value;
      else if (key === "text") node.textContent = value;
      else if (key.startsWith("on")) node.addEventListener(key.slice(2).toLowerCase(), value);
      else node.setAttribute(key, value === true ? "" : value);
    }
    for (const child of [].concat(children2)) if (child) node.appendChild(child);
    if (!node.hasAttribute("data-fk")) {
      const key = props["aria-label"] ?? props.for ?? props.id ?? props.text;
      if (key && (tag === "button" || tag === "input" || tag === "select")) {
        node.setAttribute("data-fk", String(key));
      }
    }
    return node;
  }
  __name(el, "el");
  var RANK = { merge: 3, direct: 2, hls: 2, dash: 2, image: 1, notice: 0 };
  function isPrimaryMedia(asset) {
    if (asset.kind === "notice") return false;
    if (asset.extension === "vtt" || asset.mime === "text/vtt") return false;
    if (asset.kind === "image") return false;
    return true;
  }
  __name(isPrimaryMedia, "isPrimaryMedia");
  function rankCandidates(assets2) {
    const media = dedupe(assets2).filter(isPrimaryMedia);
    const pool = media.length ? media : assets2.filter((a) => a.kind !== "notice");
    const score = /* @__PURE__ */ __name((a) => (refused.has(signatureOf(a)) ? -1e9 : 0) + (a.height || 0) * 10 + (RANK[a.kind] ?? 0), "score");
    return [...pool].sort((a, b) => score(b) - score(a));
  }
  __name(rankCandidates, "rankCandidates");
  function dedupe(assets2) {
    const seen2 = /* @__PURE__ */ new Map();
    for (const asset of assets2) {
      const key = [asset.platform, asset.kind, asset.label, asset.height || 0, asset.index || 0].join("|");
      if (!seen2.has(key)) seen2.set(key, asset);
    }
    return [...seen2.values()];
  }
  __name(dedupe, "dedupe");
  function render() {
    if (!canMount()) return;
    const { root } = mount();
    const previousKey = root.activeElement?.getAttribute?.("data-fk") ?? null;
    for (const node of [...root.children]) {
      if (node.tagName !== "STYLE") node.remove();
    }
    root.appendChild(liveRegion());
    if (toast) root.appendChild(renderToast());
    if (view === "closed") {
      const assets2 = dedupe(getAssets());
      if (!inlineActive && assets2.length && get("showLauncher")) {
        root.appendChild(renderLauncher(assets2.length));
      }
      return;
    }
    const dialog = view === "settings" ? renderSettings() : renderList();
    const backdrop = el(
      "div",
      {
        class: "backdrop",
        onClick: /* @__PURE__ */ __name((event) => {
          if (event.target === event.currentTarget) close();
        }, "onClick")
      },
      [dialog]
    );
    root.appendChild(backdrop);
    trapFocus(dialog, previousKey);
  }
  __name(render, "render");
  var announcer = null;
  function liveRegion() {
    if (!announcer) {
      announcer = el("div", { class: "sr-only", role: "status", "aria-live": "polite" });
    }
    return announcer;
  }
  __name(liveRegion, "liveRegion");
  function announce(message) {
    if (announcer) announcer.textContent = message;
  }
  __name(announce, "announce");
  function renderLauncher(count) {
    return el(
      "button",
      {
        class: "primary launcher",
        style: "position:fixed;right:1rem;bottom:1rem;z-index:2147483600;border-radius:999px;padding:0.625rem 1rem;",
        type: "button",
        "aria-label": `${t("title")} (${count})`,
        onClick: /* @__PURE__ */ __name(() => open("list"), "onClick")
      },
      [el("span", { text: `${t("download")} (${count})` })]
    );
  }
  __name(renderLauncher, "renderLauncher");
  function renderList() {
    const assets2 = dedupe(scopedAssets ?? getAssets());
    const titleId = "omg-dialog-title";
    const list = el("div", { class: "list" });
    if (!assets2.length) {
      list.appendChild(el("p", { class: "empty", text: t("empty") }));
    } else {
      for (const asset of assets2) list.appendChild(renderItem(asset));
    }
    return el(
      "div",
      { class: "dialog", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId },
      [
        el("header", {}, [
          // No subtitle here: the post title is already on every row below, and
          // showing it again put the same sentence on screen three times.
          el("h2", { id: titleId, text: t("title") }),
          el("button", {
            class: "icon",
            type: "button",
            "aria-label": t("settings"),
            title: t("settings"),
            text: "⚙",
            onClick: /* @__PURE__ */ __name(() => open("settings"), "onClick")
          }),
          el("button", {
            class: "icon",
            type: "button",
            "aria-label": t("close"),
            title: t("close"),
            text: "✕",
            onClick: close
          })
        ]),
        // No footer. "Scan again", "Deep scan" and the no-backend tagline lived
        // here, wrapped onto two lines each, and were read by nobody in the
        // moment someone opens this to grab a video. The two actions moved to
        // settings; the tagline belongs in the listing, not in the way.
        list
      ]
    );
  }
  __name(renderList, "renderList");
  function renderItem(asset) {
    if (asset.kind === "notice") {
      return el("div", { class: "item notice" }, [
        el("p", { class: "label", text: asset.label }),
        asset.detail ? el("p", { class: "meta", text: asset.detail }) : null
      ]);
    }
    const job = jobs.get(asset.id);
    const meta = [
      asset.size ? formatBytes(asset.size) : "",
      asset.duration ? formatDuration(asset.duration) : ""
    ].filter(Boolean).join(" · ");
    const body = el("div", { class: "body" }, [
      el("p", { class: "label", text: asset.label || "Media", title: asset.label || "" }),
      meta ? el("p", { class: "meta", text: meta }) : null
    ]);
    if (job) body.appendChild(renderProgress(job, asset));
    const actions = el("div", { class: "actions" });
    if (job?.status === "running") {
      actions.appendChild(
        el("button", {
          type: "button",
          text: t("cancel"),
          "aria-label": `${t("cancel")}: ${asset.label}`,
          onClick: /* @__PURE__ */ __name(() => job.controller.abort(), "onClick")
        })
      );
    } else {
      actions.appendChild(
        el("button", {
          class: "primary",
          type: "button",
          text: t("download"),
          "aria-label": `${t("download")}: ${asset.label}`,
          onClick: /* @__PURE__ */ __name(() => start(asset, { pick: false }), "onClick")
        })
      );
      if (caps.fileSystemAccess) {
        actions.appendChild(
          el("button", {
            class: "icon",
            type: "button",
            text: "⤓",
            title: t("saveAs"),
            "aria-label": `${t("saveAs")} ${asset.label}`,
            onClick: /* @__PURE__ */ __name(() => start(asset, { pick: true }), "onClick")
          })
        );
      }
    }
    return el("div", { class: "item" }, [body, actions]);
  }
  __name(renderItem, "renderItem");
  function renderProgress(job, asset) {
    const wrap = el("div", {});
    if (job.status === "running") {
      const known = job.total > 0;
      const percent = known ? Math.min(100, job.bytes / job.total * 100) : 0;
      const text = `${formatBytes(job.bytes)}${known ? ` ${t("ofSize")} ${formatBytes(job.total)}` : ""}`;
      const bar = el(
        "div",
        {
          class: `progress${known ? "" : " indeterminate"}`,
          role: "progressbar",
          "aria-label": `${t("downloading")}: ${asset.label}`,
          "aria-valuemin": "0",
          "aria-valuemax": known ? "100" : null,
          "aria-valuenow": known ? String(Math.round(percent)) : null,
          "aria-valuetext": text
        },
        [el("span", { style: known ? `width:${percent.toFixed(1)}%` : null })]
      );
      wrap.appendChild(bar);
      wrap.appendChild(el("p", { class: "meta", text: `${t("downloading")} · ${text}` }));
    } else {
      wrap.appendChild(
        el("p", {
          class: `meta state-${job.status}`,
          text: job.message || (job.status === "done" ? t("done") : t("failed"))
        })
      );
    }
    return wrap;
  }
  __name(renderProgress, "renderProgress");
  function renderToast() {
    const { status, bytes, total, message, label } = toast;
    const known = total > 0;
    const body = el("div", { class: "body" }, [
      el("p", { class: "title", text: label }),
      el("p", {
        class: `meta${status === "failed" ? " state-failed" : status === "done" ? " state-done" : ""}`,
        text: status === "running" ? `${t("downloading")} · ${formatBytes(bytes)}${known ? ` ${t("ofSize")} ${formatBytes(total)}` : ""}` : message
      })
    ]);
    if (status === "running") {
      body.appendChild(
        el(
          "div",
          {
            class: `progress${known ? "" : " indeterminate"}`,
            role: "progressbar",
            "aria-label": t("downloading"),
            "aria-valuemin": "0",
            "aria-valuemax": known ? "100" : null,
            "aria-valuenow": known ? String(Math.round(bytes / total * 100)) : null
          },
          [el("span", { style: known ? `width:${(bytes / total * 100).toFixed(1)}%` : null })]
        )
      );
    }
    return el("div", { class: "toast" }, [
      body,
      status === "running" ? el("button", { type: "button", text: t("cancel"), onClick: /* @__PURE__ */ __name(() => toast.controller?.abort(), "onClick") }) : el("button", {
        class: "icon",
        type: "button",
        "aria-label": t("close"),
        text: "✕",
        onClick: /* @__PURE__ */ __name(() => {
          toast = null;
          render();
        }, "onClick")
      })
    ]);
  }
  __name(renderToast, "renderToast");
  var FOCUSABLE = 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
  function trapFocus(dialog, previousKey) {
    const focusable = /* @__PURE__ */ __name(() => [...dialog.querySelectorAll(FOCUSABLE)], "focusable");
    dialog.setAttribute("tabindex", "-1");
    const restored = previousKey && dialog.querySelector(`[data-fk="${CSS.escape(previousKey)}"]`);
    if (restored) restored.focus();
    else dialog.focus();
    dialog.addEventListener("keydown", (event) => {
      if (event.key !== "Tab") return;
      const items = focusable();
      if (!items.length) return;
      const active2 = mounted.root.activeElement;
      const index = items.indexOf(active2);
      if (event.shiftKey && index <= 0) {
        event.preventDefault();
        items[items.length - 1].focus();
      } else if (!event.shiftKey && index === items.length - 1) {
        event.preventDefault();
        items[0].focus();
      }
    });
  }
  __name(trapFocus, "trapFocus");
  function open(next) {
    if (view === "closed") lastFocused = document.activeElement;
    view = next;
    render();
  }
  __name(open, "open");
  function close() {
    view = "closed";
    scopedAssets = null;
    render();
    if (lastFocused && document.contains(lastFocused)) lastFocused.focus();
    lastFocused = null;
  }
  __name(close, "close");
  function togglePanel() {
    if (view === "closed") open("list");
    else close();
  }
  __name(togglePanel, "togglePanel");
  function openWithAssets(assets2) {
    scopedAssets = assets2?.length ? assets2 : null;
    open("list");
  }
  __name(openWithAssets, "openWithAssets");
  function openWithNotice(label, detail = "") {
    scopedAssets = [{ id: "notice", kind: "notice", label, detail }];
    open("list");
  }
  __name(openWithNotice, "openWithNotice");
  function setInlineActive(active2) {
    inlineActive = active2;
    render();
  }
  __name(setInlineActive, "setInlineActive");
  async function downloadBest(assets2) {
    const candidates = rankCandidates(assets2);
    if (!candidates.length) {
      openWithNotice(t("empty"));
      return;
    }
    let lastError = null;
    for (const [index, asset] of candidates.entries()) {
      const result = await start(asset, {
        pick: false,
        viaToast: true,
        quiet: index < candidates.length - 1
      });
      if (result.ok) return;
      lastError = result.error;
      if (result.aborted) return;
      if (isRefusal(result.error)) refused.add(signatureOf(asset));
      if (!isRefusal(result.error)) break;
      log.info(`"${asset.label}" was refused; trying the next rendition`);
    }
    toast = {
      status: "failed",
      label: candidates[0].label,
      message: describeFailure(lastError, candidates.length),
      bytes: 0,
      total: 0
    };
    render();
  }
  __name(downloadBest, "downloadBest");
  function isRefusal(err) {
    return err?.name === "HttpError" && err.status >= 400 && err.status < 500;
  }
  __name(isRefusal, "isRefusal");
  function describeFailure(err, tried) {
    if (isRefusal(err)) {
      return `${t("failed")}: the platform refused every rendition (HTTP ${err.status}, ${tried} tried). The media URLs are bound to the page's own player session.`;
    }
    return `${t("failed")}: ${err?.message ?? "unknown error"}`;
  }
  __name(describeFailure, "describeFailure");
  async function start(asset, { pick: pick2, viaToast = false, quiet = false }) {
    const wantsPicker = pick2 || get("askWhereToSave");
    const handlePromise = wantsPicker ? pickSaveLocation(suggestName(asset), asset.extension || "mp4") : Promise.resolve(null);
    const controller = new AbortController();
    const job = { status: "running", bytes: 0, total: asset.size || 0, message: "", controller };
    jobs.set(asset.id, job);
    if (viaToast) {
      toast = { status: "running", bytes: 0, total: asset.size || 0, label: asset.label, controller };
    }
    announce(`${t("downloading")}: ${asset.label}`);
    render();
    try {
      const handle = await handlePromise;
      if (wantsPicker && !handle) {
        jobs.delete(asset.id);
        toast = null;
        render();
        return { ok: false, aborted: true };
      }
      const plan = await planDownload(asset, { signal: controller.signal });
      job.total = plan.expectedSize || job.total;
      if (toast) toast.total = job.total;
      render();
      const result = await runPlan(plan, {
        handle,
        signal: controller.signal,
        onProgress: /* @__PURE__ */ __name((update) => {
          if (update.phase !== "downloading") return;
          job.bytes = update.bytes;
          job.total = update.total || job.total;
          if (toast) {
            toast.bytes = update.bytes;
            toast.total = job.total;
          }
          throttledRender();
        }, "onProgress")
      });
      job.status = "done";
      job.message = `${result.strategy === "handoff" ? t("handedOff") : t("done")} · ${result.filename}`;
      if (toast) {
        toast.status = "done";
        toast.message = `${t("done")} · ${result.filename}`;
        scheduleToastDismissal();
      }
      announce(`${t("done")}: ${result.filename}`);
      render();
      return { ok: true };
    } catch (err) {
      if (err?.name === "AbortError") {
        jobs.delete(asset.id);
        toast = null;
        render();
        return { ok: false, aborted: true };
      }
      job.status = "failed";
      job.message = err instanceof DrmRefused ? t("drmRefused") : `${t("failed")}: ${err.message}`;
      if (toast && !quiet) {
        toast.status = "failed";
        toast.message = job.message;
      }
      announce(job.message);
      log.error("download failed", err);
      render();
      return { ok: false, error: err };
    }
  }
  __name(start, "start");
  var dismissTimer = null;
  function scheduleToastDismissal() {
    clearTimeout(dismissTimer);
    dismissTimer = setTimeout(() => {
      if (toast?.status === "done") {
        toast = null;
        render();
      }
    }, 6e3);
  }
  __name(scheduleToastDismissal, "scheduleToastDismissal");
  function formatDuration(seconds) {
    const total = Math.round(seconds);
    if (!Number.isFinite(total) || total <= 0) return "";
    const m = Math.floor(total / 60);
    const s = total % 60;
    return m >= 60 ? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
  }
  __name(formatDuration, "formatDuration");
  function suggestName(asset) {
    return `${(asset.title || asset.postId || "media").slice(0, 60)}.${asset.extension || "mp4"}`;
  }
  __name(suggestName, "suggestName");
  var renderQueued = false;
  function throttledRender() {
    if (renderQueued) return;
    renderQueued = true;
    requestAnimationFrame(() => {
      renderQueued = false;
      render();
    });
  }
  __name(throttledRender, "throttledRender");
  function renderSettings() {
    const settings = getSettings();
    const form = el("div", { class: "settings" });
    let seq = 0;
    const nextId = /* @__PURE__ */ __name(() => `omg-f${seq++}`, "nextId");
    const field = /* @__PURE__ */ __name((key, labelText, control, hint) => {
      const id = nextId();
      control.setAttribute("id", id);
      return el("div", { class: "field" }, [
        el("label", { for: id, text: labelText }),
        control,
        hint ? el("p", { class: "hint", text: hint }) : null
      ]);
    }, "field");
    const textInput = /* @__PURE__ */ __name((key) => el("input", {
      type: "text",
      value: settings[key],
      onChange: /* @__PURE__ */ __name((e) => set({ [key]: e.target.value }).then(render), "onChange")
    }), "textInput");
    const numberInput = /* @__PURE__ */ __name((key, min, max) => el("input", {
      type: "number",
      min: String(min),
      max: String(max),
      value: String(settings[key]),
      onChange: /* @__PURE__ */ __name((e) => set({ [key]: Math.max(min, Math.min(max, Number(e.target.value) || min)) }).then(render), "onChange")
    }), "numberInput");
    const selectInput = /* @__PURE__ */ __name((key, options) => el(
      "select",
      { onChange: /* @__PURE__ */ __name((e) => set({ [key]: e.target.value }).then(render), "onChange") },
      options.map(
        ([value, text]) => el("option", { value, selected: settings[key] === value, text })
      )
    ), "selectInput");
    const checkbox = /* @__PURE__ */ __name((key, labelText, { hint = "", disabled = false } = {}) => {
      const id = nextId();
      return el("div", { class: "field inline" }, [
        el("input", {
          type: "checkbox",
          id,
          checked: !!settings[key],
          disabled,
          onChange: /* @__PURE__ */ __name((e) => set({ [key]: e.target.checked }).then(render), "onChange")
        }),
        el("label", { for: id, text: labelText }),
        hint ? el("p", { class: "hint", text: hint }) : null
      ]);
    }, "checkbox");
    form.append(
      field(
        "filenameTemplate",
        t("settingFilename"),
        textInput("filenameTemplate"),
        "{platform} {author} {title} {id} {quality} {date} {index}"
      ),
      field(
        "preferredQuality",
        t("settingQuality"),
        selectInput("preferredQuality", [
          ["auto", t("qualityAuto")],
          ["lowest", t("qualityLowest")],
          ["1080", "1080p"],
          ["720", "720p"],
          ["480", "480p"]
        ])
      ),
      checkbox("askWhereToSave", t("settingAskWhere"), {
        hint: t("settingAskWhereHint"),
        disabled: !caps.fileSystemAccess
      }),
      checkbox("deepInspection", t("settingDeep"), { hint: t("settingDeepHint") }),
      checkbox("autoMerge", t("settingAutoMerge")),
      checkbox("showLauncher", t("settingLauncher")),
      field("segmentConcurrency", t("settingConcurrency"), numberInput("segmentConcurrency", 1, 8)),
      field(
        "locale",
        t("settingLocale"),
        selectInput("locale", [["auto", "Auto"], ...AVAILABLE_LOCALES.map((c) => [c, c])])
      ),
      field(
        "logLevel",
        t("settingLog"),
        selectInput("logLevel", [
          ["silent", "silent"],
          ["warn", "warn"],
          ["info", "info"],
          ["debug", "debug"]
        ])
      )
    );
    const titleId = "omg-settings-title";
    return el(
      "div",
      { class: "dialog", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId },
      [
        el("header", {}, [
          el("button", {
            class: "icon",
            type: "button",
            "aria-label": t("close"),
            text: "←",
            onClick: /* @__PURE__ */ __name(() => open("list"), "onClick")
          }),
          el("h2", { id: titleId, text: t("settingsTitle") }),
          el("button", {
            class: "icon",
            type: "button",
            "aria-label": t("close"),
            text: "✕",
            onClick: close
          })
        ]),
        form,
        // The two scan actions live here now rather than under the media list,
        // where they were permanent furniture for something almost nobody needs.
        el("footer", {}, [
          el("button", {
            type: "button",
            text: t("scan"),
            onClick: /* @__PURE__ */ __name(() => bus.emit(EVENTS.NAVIGATED, {}), "onClick")
          }),
          el("button", {
            type: "button",
            text: t("deepScan"),
            onClick: /* @__PURE__ */ __name((event) => {
              enableDeepInspection();
              event.currentTarget.disabled = true;
              bus.emit(EVENTS.NAVIGATED, {});
            }, "onClick")
          }),
          el("span", { class: "spacer" }),
          el("button", {
            type: "button",
            text: t("reset"),
            onClick: /* @__PURE__ */ __name(() => reset().then(applySettings).then(render), "onClick")
          })
        ])
      ]
    );
  }
  __name(renderSettings, "renderSettings");
  function applySettings() {
    setLocale(get("locale"));
  }
  __name(applySettings, "applySettings");
  function initPanel() {
    applySettings();
    bus.on(EVENTS.ASSETS_CHANGED, () => throttledRender());
    bus.on(EVENTS.NAVIGATED, () => {
      jobs.clear();
      refused.clear();
      clearAssets();
      render();
    });
    document.addEventListener(
      "keydown",
      (event) => {
        if (event.key === "Escape" && view !== "closed") {
          event.stopPropagation();
          close();
        }
      },
      true
    );
    render();
  }
  __name(initPanel, "initPanel");

  // src/platforms/shared.js
  init_log();
  init_env();
  function readEmbeddedJson({ globalName, scriptPattern }) {
    if (globalName) {
      const value = pageWindow[globalName];
      if (value && typeof value === "object") return value;
    }
    if (!scriptPattern) return null;
    for (const script of document.querySelectorAll("script")) {
      const text = script.textContent;
      if (!text || text.length < 32) continue;
      const match = scriptPattern.exec(text);
      if (!match) continue;
      try {
        return JSON.parse(match[1]);
      } catch {
        const recovered = balancedSlice(text, match.index + match[0].indexOf("{"));
        if (recovered) {
          try {
            return JSON.parse(recovered);
          } catch (err) {
            log.debug("embedded JSON did not parse", err);
          }
        }
      }
    }
    return null;
  }
  __name(readEmbeddedJson, "readEmbeddedJson");
  function balancedSlice(text, start2) {
    if (text[start2] !== "{" && text[start2] !== "[") return null;
    const open2 = text[start2];
    const close2 = open2 === "{" ? "}" : "]";
    let depth = 0;
    let inString = false;
    let escaped = false;
    for (let i = start2; i < text.length; i++) {
      const ch = text[i];
      if (escaped) {
        escaped = false;
        continue;
      }
      if (ch === "\\") {
        escaped = true;
        continue;
      }
      if (ch === '"') {
        inString = !inString;
        continue;
      }
      if (inString) continue;
      if (ch === open2) depth++;
      else if (ch === close2) {
        depth--;
        if (depth === 0) return text.slice(start2, i + 1);
      }
    }
    return null;
  }
  __name(balancedSlice, "balancedSlice");
  function findAll(root, predicate, maxDepth = 12) {
    const out = [];
    const queue = [{ node: root, depth: 0 }];
    const seen2 = /* @__PURE__ */ new Set();
    while (queue.length) {
      const { node, depth } = queue.shift();
      if (!node || typeof node !== "object" || depth > maxDepth) continue;
      if (seen2.has(node)) continue;
      seen2.add(node);
      for (const [key, value] of Object.entries(node)) {
        if (predicate(key, value)) out.push(value);
        if (value && typeof value === "object") queue.push({ node: value, depth: depth + 1 });
      }
    }
    return out;
  }
  __name(findAll, "findAll");
  function metaContent(...names) {
    for (const name of names) {
      const el2 = document.querySelector(`meta[property="${name}"]`) || document.querySelector(`meta[name="${name}"]`);
      const content = el2?.getAttribute("content");
      if (content) return content.trim();
    }
    return "";
  }
  __name(metaContent, "metaContent");

  // src/platforms/tiktok.js
  var observed = /* @__PURE__ */ new Map();
  function collectItems(root) {
    return findAll(
      root,
      (key, value) => key === "itemStruct" || value && typeof value === "object" && value.video && value.id && value.author
    );
  }
  __name(collectItems, "collectItems");
  function assetsFromItem(item, referer) {
    const meta = {
      platform: "tiktok",
      title: item.desc || "",
      author: item.author?.uniqueId || item.author?.nickname || "",
      postId: item.id || "",
      referer
    };
    const out = [];
    const video = item.video || {};
    if (video.downloadAddr) {
      out.push({
        ...meta,
        kind: "direct",
        url: video.downloadAddr,
        extension: "mp4",
        mime: "video/mp4",
        width: video.width || 0,
        height: video.height || 0,
        duration: video.duration || 0,
        label: `${video.height ? `${video.height}p` : "Video"} · MP4 · download copy`
      });
    }
    if (video.playAddr && video.playAddr !== video.downloadAddr) {
      out.push({
        ...meta,
        kind: "direct",
        url: video.playAddr,
        extension: "mp4",
        mime: "video/mp4",
        width: video.width || 0,
        height: video.height || 0,
        duration: video.duration || 0,
        label: `${video.height ? `${video.height}p` : "Video"} · MP4 · playback copy`
      });
    }
    for (const [index, bitrate] of (video.bitrateInfo || []).entries()) {
      const url = bitrate?.PlayAddr?.UrlList?.at(-1);
      if (!url) continue;
      out.push({
        ...meta,
        kind: "direct",
        url,
        extension: "mp4",
        mime: "video/mp4",
        size: Number(bitrate.PlayAddr.DataSize) || 0,
        label: `${bitrate.GearName || `Quality ${index + 1}`} · ${Math.round((bitrate.Bitrate || 0) / 1e3)} kbps`
      });
    }
    const images = item.imagePost?.images || [];
    images.forEach((image, index) => {
      const url = image?.imageURL?.urlList?.[0];
      if (!url) return;
      out.push({
        ...meta,
        kind: "image",
        url,
        extension: "jpg",
        width: image.imageWidth || 0,
        height: image.imageHeight || 0,
        index: index + 1,
        label: `Photo ${index + 1}/${images.length}`
      });
    });
    if (video.cover || video.dynamicCover) {
      out.push({
        ...meta,
        kind: "image",
        url: video.cover || video.dynamicCover,
        extension: "jpg",
        label: "Cover image"
      });
    }
    const music = item.music || {};
    if (music.playUrl) {
      out.push({
        ...meta,
        kind: "direct",
        url: music.playUrl,
        extension: "mp3",
        mime: "audio/mpeg",
        title: music.title || meta.title,
        author: music.authorName || meta.author,
        label: `Audio track · ${music.title || "sound"}`
      });
    }
    return out;
  }
  __name(assetsFromItem, "assetsFromItem");
  function actionRails() {
    const rails = /* @__PURE__ */ new Set();
    for (const like of document.querySelectorAll('[data-e2e="like-icon"], [data-e2e="browse-like-icon"]')) {
      let node = like.parentElement;
      for (let depth = 0; node && depth < 6; depth++, node = node.parentElement) {
        if (node.querySelector('[data-e2e*="comment-icon"]')) {
          rails.add(node);
          break;
        }
      }
    }
    return [...rails];
  }
  __name(actionRails, "actionRails");
  function postContainerOf(element) {
    let node = element;
    for (let depth = 0; node && depth < 12; depth++, node = node.parentElement) {
      if (node.querySelector?.("video")) return node;
    }
    return null;
  }
  __name(postContainerOf, "postContainerOf");
  function assetsFromPlayer(container2, id) {
    const post = postContainerOf(container2);
    const video = post?.querySelector("video");
    const src = video?.currentSrc || video?.src || "";
    if (!src || src.startsWith("blob:")) return [];
    const caption = post?.querySelector('[data-e2e*="video-desc"], [data-e2e*="browse-video-desc"]');
    return [
      {
        platform: "tiktok",
        kind: "direct",
        url: src,
        extension: "mp4",
        mime: "video/mp4",
        width: video.videoWidth || 0,
        height: video.videoHeight || 0,
        duration: Number.isFinite(video.duration) ? video.duration : 0,
        title: caption?.textContent?.trim() || "",
        postId: id || "",
        referer: location.href,
        label: `${video.videoHeight ? `${video.videoHeight}p` : "Video"} · MP4`
      }
    ];
  }
  __name(assetsFromPlayer, "assetsFromPlayer");
  function hydratedItem(id) {
    const hydrated = readEmbeddedJson({
      scriptPattern: /id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>(\{.+?\})<\/script>/s
    });
    if (!hydrated) return null;
    for (const item of collectItems(hydrated)) {
      if (item?.id === id) return item;
    }
    return null;
  }
  __name(hydratedItem, "hydratedItem");
  function videoIdNear(element) {
    let node = element;
    for (let depth = 0; node && depth < 12; depth++, node = node.parentElement) {
      const link = node.querySelector?.('a[href*="/video/"], a[href*="/photo/"]');
      const match = link && /\/(?:video|photo)\/(\d+)/.exec(link.getAttribute("href") || "");
      if (match) return match[1];
    }
    return /\/(?:video|photo)\/(\d+)/.exec(location.pathname)?.[1] || "";
  }
  __name(videoIdNear, "videoIdNear");
  register({
    id: "tiktok",
    name: "TikTok",
    matches(url) {
      return /(^|\.)tiktok\.com$/.test(url.hostname);
    },
    inline: [
      {
        id: "tiktok-rail",
        container: actionRails,
        requireReference: false,
        icon: "solid",
        label: "Download media",
        resolve(container2) {
          const id = videoIdNear(container2);
          const item = id && observed.get(id) || id && hydratedItem(id);
          if (item) return assetsFromItem(item, location.href);
          return assetsFromPlayer(container2, id);
        }
      }
    ],
    observe() {
      watchBody(
        // The old pattern required "/api/item/" and TikTok serves
        // "/api/item_list/", so the most common feed endpoint never matched.
        (url) => /\/api\/(v\d+\/)?(item|post|recommend|related|detail|reply)/.test(url),
        (_url, text) => {
          try {
            for (const item of collectItems(JSON.parse(text))) {
              if (item?.id) observed.set(item.id, item);
            }
          } catch {
          }
        }
      );
    },
    async collect({ url }) {
      const hydrated = readEmbeddedJson({
        scriptPattern: /id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>(\{.+?\})<\/script>/s
      });
      const items = /* @__PURE__ */ new Map();
      if (hydrated) {
        for (const item of collectItems(hydrated)) {
          if (item?.id) items.set(item.id, item);
        }
      }
      for (const [id, item] of observed) items.set(id, item);
      const pathId = /\/video\/(\d+)/.exec(url.pathname)?.[1] || /\/photo\/(\d+)/.exec(url.pathname)?.[1];
      const chosen = pathId && items.has(pathId) ? [items.get(pathId)] : [...items.values()].slice(0, 8);
      return chosen.flatMap((item) => assetsFromItem(item, url.href));
    }
  });

  // src/platforms/instagram.js
  var observed2 = [];
  var MAX_OBSERVED = 60;
  function remember2(node) {
    if (!node || typeof node !== "object") return;
    const code = node.code || node.shortcode || node.pk || node.id;
    if (!code) return;
    if (observed2.some((n) => (n.code || n.shortcode || n.pk || n.id) === code)) return;
    observed2.push(node);
    if (observed2.length > MAX_OBSERVED) observed2.shift();
  }
  __name(remember2, "remember");
  function harvest(root) {
    for (const node of findAll(
      root,
      (_key, value) => value && typeof value === "object" && (Array.isArray(value.video_versions) || Array.isArray(value.carousel_media) || value.image_versions2 && value.image_versions2.candidates)
    )) {
      remember2(node);
    }
  }
  __name(harvest, "harvest");
  function bestCandidate(list) {
    return (list || []).reduce(
      (best, item) => (item?.width || 0) > (best?.width || 0) ? item : best,
      null
    );
  }
  __name(bestCandidate, "bestCandidate");
  function assetsFromNode(node, meta, index = 0) {
    const out = [];
    const position = index ? { index } : {};
    if (Array.isArray(node.carousel_media) && node.carousel_media.length) {
      node.carousel_media.forEach((child, i) => {
        out.push(...assetsFromNode(child, meta, i + 1));
      });
      return out;
    }
    const videos = (node.video_versions || []).slice().sort((a, b) => (b.width || 0) - (a.width || 0));
    for (const video of videos.slice(0, 3)) {
      out.push({
        ...meta,
        ...position,
        kind: "direct",
        url: video.url,
        extension: "mp4",
        mime: "video/mp4",
        width: video.width || 0,
        height: video.height || 0,
        label: `${video.height ? `${video.height}p` : "Video"} · MP4${index ? ` · item ${index}` : ""}`
      });
    }
    if (!videos.length) {
      const image = bestCandidate(node.image_versions2?.candidates);
      if (image?.url) {
        out.push({
          ...meta,
          ...position,
          kind: "image",
          url: image.url,
          extension: "jpg",
          width: image.width || 0,
          height: image.height || 0,
          label: `Photo ${image.width}×${image.height}${index ? ` · item ${index}` : ""}`
        });
      }
    }
    return out;
  }
  __name(assetsFromNode, "assetsFromNode");
  function shortcodeFrom(pathname) {
    return /\/(?:p|reel|reels|tv)\/([\w-]+)/.exec(pathname)?.[1] || "";
  }
  __name(shortcodeFrom, "shortcodeFrom");
  function actionRows() {
    const rows = [];
    for (const article of document.querySelectorAll("article")) {
      let best = null;
      for (const candidate of article.querySelectorAll("section, div")) {
        const icons = candidate.querySelectorAll(":scope > * svg, :scope > svg");
        if (icons.length < 3) continue;
        const box = candidate.getBoundingClientRect();
        if (box.width < 150 || box.height > 80 || box.height === 0) continue;
        if (!best || box.height < best.box.height) best = { node: candidate, box };
      }
      if (best) rows.push(best.node);
    }
    return rows;
  }
  __name(actionRows, "actionRows");
  function shortcodeNear(element) {
    const article = element.closest("article") ?? element;
    for (const link of article.querySelectorAll('a[href*="/p/"], a[href*="/reel/"]')) {
      const match = /\/(?:p|reel)\/([\w-]+)/.exec(link.getAttribute("href") || "");
      if (match) return match[1];
    }
    return shortcodeFrom(location.pathname);
  }
  __name(shortcodeNear, "shortcodeNear");
  register({
    id: "instagram",
    name: "Instagram",
    matches(url) {
      return /(^|\.)(instagram\.com|threads\.net|threads\.com)$/.test(url.hostname);
    },
    inline: [
      {
        id: "instagram-actions",
        container: actionRows,
        requireReference: false,
        label: "Download media",
        resolve(container2) {
          const code = shortcodeNear(container2);
          const node = observed2.find((n) => n.code === code || n.shortcode === code) ?? observed2.at(-1);
          if (!node) return [];
          return assetsFromNode(node, {
            platform: location.hostname.includes("threads") ? "threads" : "instagram",
            title: metaContent("og:title") || document.title,
            author: node.user?.username || node.owner?.username || "",
            postId: code,
            referer: location.href
          });
        }
      }
    ],
    observe() {
      watchBody(
        (url) => /\/(graphql\/query|api\/v1\/(media|feed|clips))/.test(url),
        (_url, text) => {
          try {
            harvest(JSON.parse(text));
          } catch {
          }
        }
      );
    },
    async collect({ url }) {
      const shortcode = shortcodeFrom(url.pathname);
      const meta = {
        platform: url.hostname.includes("threads") ? "threads" : "instagram",
        title: metaContent("og:title", "twitter:title") || document.title,
        author: observed2.at(-1)?.user?.username || observed2.at(-1)?.owner?.username || /\/([\w.]+)\//.exec(url.pathname)?.[1] || "",
        postId: shortcode,
        referer: url.href
      };
      const matching = observed2.filter(
        (node) => !shortcode || node.code === shortcode || node.shortcode === shortcode
      );
      const nodes = matching.length ? matching : observed2.slice(-4);
      const assets2 = nodes.flatMap(
        (node) => assetsFromNode(node, { ...meta, author: node.user?.username || meta.author })
      );
      if (!assets2.length) {
        const ogVideo = metaContent("og:video", "og:video:secure_url");
        const ogImage = metaContent("og:image");
        if (ogVideo) {
          assets2.push({ ...meta, kind: "direct", url: ogVideo, extension: "mp4", label: "Video · MP4" });
        } else if (ogImage) {
          assets2.push({ ...meta, kind: "image", url: ogImage, extension: "jpg", label: "Photo" });
        }
      }
      return assets2;
    }
  });

  // src/platforms/twitter.js
  var observed3 = /* @__PURE__ */ new Map();
  var MAX_OBSERVED2 = 80;
  function harvest2(root) {
    const results = findAll(
      root,
      (_key, value) => value && typeof value === "object" && value.legacy && (value.legacy.extended_entities || value.legacy.entities) && (value.rest_id || value.legacy.id_str)
    );
    for (const result of results) {
      const legacy = result.legacy;
      const id = result.rest_id || legacy.id_str;
      const media = legacy.extended_entities?.media || legacy.entities?.media || [];
      if (!id || !media.length) continue;
      if (observed3.size > MAX_OBSERVED2) {
        observed3.delete(observed3.keys().next().value);
      }
      observed3.set(id, {
        media,
        text: legacy.full_text || "",
        author: result.core?.user_results?.result?.legacy?.screen_name || result.core?.user_results?.result?.core?.screen_name || ""
      });
    }
  }
  __name(harvest2, "harvest");
  function assetsFromMedia(media, meta) {
    const out = [];
    media.forEach((item, index) => {
      const position = media.length > 1 ? { index: index + 1 } : {};
      if (item.type === "photo") {
        const base = item.media_url_https || "";
        const format = /\.(\w+)$/.exec(base)?.[1] || "jpg";
        out.push({
          ...meta,
          ...position,
          kind: "image",
          url: base.replace(/\.\w+$/, "") + `?format=${format}&name=orig`,
          extension: format,
          width: item.original_info?.width || 0,
          height: item.original_info?.height || 0,
          label: `Photo${media.length > 1 ? ` ${index + 1}/${media.length}` : ""} · original size`
        });
        return;
      }
      const variants = item.video_info?.variants || [];
      const progressive = variants.filter((v) => v.content_type === "video/mp4" && v.url).sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
      for (const variant of progressive) {
        const height = Number(/\/(\d+)x(\d+)\//.exec(variant.url)?.[2]) || 0;
        out.push({
          ...meta,
          ...position,
          kind: "direct",
          url: variant.url,
          extension: "mp4",
          mime: "video/mp4",
          height,
          duration: (item.video_info?.duration_millis || 0) / 1e3,
          label: `${height ? `${height}p` : "Video"} · MP4 · ${Math.round((variant.bitrate || 0) / 1e3)} kbps`
        });
      }
      const hls = variants.find((v) => /mpegurl/i.test(v.content_type || ""));
      if (hls) {
        out.push({
          ...meta,
          ...position,
          kind: "hls",
          manifestUrl: hls.url,
          extension: "mp4",
          duration: (item.video_info?.duration_millis || 0) / 1e3,
          label: "Best available · HLS (fMP4, joined locally)"
        });
      }
      if (item.media_url_https) {
        out.push({
          ...meta,
          ...position,
          kind: "image",
          url: `${item.media_url_https}:orig`,
          extension: "jpg",
          label: "Video poster frame"
        });
      }
    });
    return out;
  }
  __name(assetsFromMedia, "assetsFromMedia");
  function statusIdNear(element) {
    const article = element.closest("article") ?? element;
    for (const link of article.querySelectorAll('a[href*="/status/"]')) {
      const match = /\/status\/(\d+)/.exec(link.getAttribute("href") || "");
      if (match) return match[1];
    }
    return "";
  }
  __name(statusIdNear, "statusIdNear");
  register({
    id: "twitter",
    name: "X (Twitter)",
    matches(url) {
      return /(^|\.)(twitter\.com|x\.com)$/.test(url.hostname);
    },
    /**
     * A button at the end of each post's action row, after Share.
     *
     * `data-testid` is the right hook here: X ships it for its own end-to-end
     * tests, so it survives redesigns and — unlike `aria-label` — does not change
     * with the interface language.
     */
    inline: [
      {
        id: "twitter-actions",
        container: 'article[data-testid="tweet"] div[role="group"]',
        requireReference: false,
        label: "Download media",
        resolve(container2) {
          const id = statusIdNear(container2);
          const entry = id && observed3.get(id);
          if (!entry) return [];
          return assetsFromMedia(entry.media, {
            platform: "twitter",
            title: entry.text.slice(0, 80),
            author: entry.author,
            postId: id,
            referer: location.href
          });
        }
      }
    ],
    observe() {
      watchBody(
        (url) => /\/i\/api\/graphql\//.test(url) || /\/graphql\//.test(url),
        (_url, text) => {
          try {
            harvest2(JSON.parse(text));
          } catch {
          }
        }
      );
    },
    async collect({ url }) {
      const statusId = /\/status(?:es)?\/(\d+)/.exec(url.pathname)?.[1] || "";
      const entries = statusId && observed3.has(statusId) ? [[statusId, observed3.get(statusId)]] : [...observed3.entries()].slice(-3);
      return entries.flatMap(
        ([id, entry]) => assetsFromMedia(entry.media, {
          platform: "twitter",
          title: entry.text.slice(0, 80),
          author: entry.author,
          postId: id,
          referer: url.href
        })
      );
    }
  });

  // src/platforms/reddit.js
  init_net();
  init_log();
  var AUDIO_CANDIDATES = ["DASH_AUDIO_256.mp4", "DASH_AUDIO_128.mp4", "DASH_AUDIO_64.mp4", "audio"];
  async function firstReachable(base, names, referer) {
    const { probe: probe2 } = await Promise.resolve().then(() => (init_net(), net_exports));
    for (const name of names) {
      const candidate = `${base}/${name}`;
      try {
        const info = await probe2(candidate, { referer });
        if (info.size > 0) return candidate;
      } catch {
      }
    }
    return null;
  }
  __name(firstReachable, "firstReachable");
  function postsFrom(payload) {
    return findAll(
      payload,
      (_key, value) => value && typeof value === "object" && value.kind === "t3" && value.data
    ).map((node) => node.data);
  }
  __name(postsFrom, "postsFrom");
  async function assetsFromPost(data, referer) {
    const meta = {
      platform: "reddit",
      title: data.title || "",
      author: data.author ? `u_${data.author}` : "",
      postId: data.id || "",
      referer
    };
    const out = [];
    const video = data.secure_media?.reddit_video || data.media?.reddit_video;
    if (video?.fallback_url) {
      const fallback = video.fallback_url.split("?")[0];
      const base = fallback.replace(/\/DASH_[^/]+$/, "");
      const audioUrl = await firstReachable(base, AUDIO_CANDIDATES, referer);
      if (audioUrl) {
        out.push({
          ...meta,
          kind: "merge",
          videoUrl: fallback,
          audioUrl,
          extension: "mp4",
          height: video.height || 0,
          width: video.width || 0,
          duration: video.duration || 0,
          label: `${video.height}p · MP4 · video+audio merged locally`
        });
      }
      out.push({
        ...meta,
        kind: "direct",
        url: fallback,
        extension: "mp4",
        height: video.height || 0,
        width: video.width || 0,
        label: `${video.height}p · MP4 · ${audioUrl ? "video only" : "no separate audio found"}`
      });
      if (video.dash_url) {
        out.push({
          ...meta,
          kind: "dash",
          manifestUrl: video.dash_url.split("?")[0],
          extension: "mp4",
          label: "All renditions · DASH manifest"
        });
      }
    }
    if (data.is_gallery && data.media_metadata) {
      const order = data.gallery_data?.items || [];
      order.forEach((item, index) => {
        const entry = data.media_metadata[item.media_id];
        const source = entry?.s;
        if (!source) return;
        const url = (source.u || source.gif || source.mp4 || "").replace(/&amp;/g, "&");
        if (!url) return;
        out.push({
          ...meta,
          kind: source.mp4 ? "direct" : "image",
          url,
          extension: source.mp4 ? "mp4" : entry.m?.split("/")[1] || "jpg",
          width: source.x || 0,
          height: source.y || 0,
          index: index + 1,
          label: `Gallery ${index + 1}/${order.length} · ${source.x}×${source.y}`
        });
      });
    }
    if (!out.length && /\.(jpe?g|png|gif|webp)$/i.test(data.url_overridden_by_dest || data.url || "")) {
      const url = data.url_overridden_by_dest || data.url;
      out.push({
        ...meta,
        kind: "image",
        url,
        extension: /\.(\w+)$/.exec(url)[1].toLowerCase(),
        label: "Image"
      });
    }
    return out;
  }
  __name(assetsFromPost, "assetsFromPost");
  function postActionRows() {
    const rows = [];
    for (const post of document.querySelectorAll("shreddit-post")) {
      const share = post.querySelector('[data-post-click-location="share"]') || post.querySelector("shreddit-post-share-button");
      const row = share?.parentElement ?? post.querySelector('[slot="post-menu-container"]')?.parentElement;
      if (row) rows.push(row);
    }
    return rows;
  }
  __name(postActionRows, "postActionRows");
  register({
    id: "reddit",
    name: "Reddit",
    matches(url) {
      return /(^|\.)(reddit\.com|redditmedia\.com)$/.test(url.hostname);
    },
    inline: [
      {
        id: "reddit-actions",
        container: postActionRows,
        requireReference: false,
        label: "Download media",
        async resolve(container2) {
          const post = container2.closest("shreddit-post");
          const permalink = post?.getAttribute("permalink");
          if (!permalink) return [];
          try {
            const payload = await getJson(
              `${location.origin}${permalink.replace(/\/$/, "")}.json?raw_json=1&limit=1`,
              { referer: location.href }
            );
            const results = await Promise.all(
              postsFrom(payload).slice(0, 1).map((p) => assetsFromPost(p, location.href))
            );
            return results.flat();
          } catch (err) {
            log.warn("reddit per-post lookup failed", err);
            return [];
          }
        }
      }
    ],
    async collect({ url }) {
      const permalink = /\/comments\/([a-z0-9]+)/i.exec(url.pathname);
      if (!permalink) return [];
      try {
        const payload = await getJson(
          `${url.origin}${url.pathname.replace(/\/$/, "")}.json?raw_json=1&limit=1`,
          { referer: url.href }
        );
        const posts = postsFrom(payload);
        const results = await Promise.all(posts.slice(0, 2).map((p) => assetsFromPost(p, url.href)));
        return results.flat();
      } catch (err) {
        log.warn("reddit JSON view failed", err);
        return [];
      }
    }
  });

  // src/platforms/facebook.js
  var VIDEO_FIELDS = [
    "browser_native_hd_url",
    "playable_url_quality_hd",
    "browser_native_sd_url",
    "playable_url"
  ];
  var observed4 = [];
  function harvest3(root) {
    for (const node of findAll(
      root,
      (_key, value) => value && typeof value === "object" && VIDEO_FIELDS.some((field) => value[field])
    )) {
      if (observed4.length > 40) observed4.shift();
      if (!observed4.includes(node)) observed4.push(node);
    }
  }
  __name(harvest3, "harvest");
  function harvestInlineScripts() {
    for (const script of document.querySelectorAll('script[type="application/json"]')) {
      const text = script.textContent;
      if (!text || !VIDEO_FIELDS.some((field) => text.includes(field))) continue;
      try {
        harvest3(JSON.parse(text));
      } catch {
        const slice = balancedSlice(text, text.indexOf("{"));
        if (slice) {
          try {
            harvest3(JSON.parse(slice));
          } catch {
          }
        }
      }
    }
  }
  __name(harvestInlineScripts, "harvestInlineScripts");
  function assetsFrom(node, meta) {
    const out = [];
    const seen2 = /* @__PURE__ */ new Set();
    const push = /* @__PURE__ */ __name((url, label, height) => {
      if (!url || seen2.has(url)) return;
      seen2.add(url);
      out.push({
        ...meta,
        kind: "direct",
        url,
        extension: "mp4",
        mime: "video/mp4",
        height: height || 0,
        label
      });
    }, "push");
    push(node.browser_native_hd_url || node.playable_url_quality_hd, "HD · MP4", node.height || 0);
    push(node.browser_native_sd_url || node.playable_url, "SD · MP4", 0);
    if (typeof node.dash_manifest === "string" && node.dash_manifest.includes("<MPD")) {
      out.push({
        ...meta,
        kind: "dash-inline",
        manifestXml: node.dash_manifest,
        manifestUrl: node.dash_manifest_url || location.href,
        extension: "mp4",
        label: "All renditions · inline DASH manifest"
      });
    }
    return out;
  }
  __name(assetsFrom, "assetsFrom");
  function actionBars() {
    const bars = [];
    for (const article of document.querySelectorAll('div[role="article"]')) {
      for (const candidate of article.querySelectorAll("div")) {
        const buttons = candidate.querySelectorAll(':scope > [role="button"], :scope > div > [role="button"]');
        if (buttons.length >= 3 && candidate.childElementCount <= 5) {
          bars.push(candidate);
          break;
        }
      }
    }
    return bars;
  }
  __name(actionBars, "actionBars");
  register({
    id: "facebook",
    name: "Facebook",
    matches(url) {
      return /(^|\.)(facebook\.com|fb\.watch|fb\.com)$/.test(url.hostname);
    },
    inline: [
      {
        id: "facebook-actions",
        container: actionBars,
        requireReference: false,
        label: "Download media",
        resolve() {
          harvestInlineScripts();
          const meta = {
            platform: "facebook",
            title: metaContent("og:title") || document.title,
            referer: location.href
          };
          return observed4.slice(-4).flatMap((node) => assetsFrom(node, meta));
        }
      }
    ],
    observe() {
      watchBody(
        (url) => /\/api\/graphql\//.test(url),
        (_url, text) => {
          for (const line of text.split("\n")) {
            const trimmed = line.trim();
            if (!trimmed.startsWith("{")) continue;
            try {
              harvest3(JSON.parse(trimmed));
            } catch {
            }
          }
        }
      );
    },
    async collect({ url }) {
      harvestInlineScripts();
      const meta = {
        platform: "facebook",
        title: metaContent("og:title") || document.title,
        author: metaContent("og:site_name") === "Facebook" ? "" : metaContent("og:site_name"),
        postId: /\/videos\/(?:[\w.-]+\/)?(\d+)/.exec(url.pathname)?.[1] || url.searchParams.get("v") || /\/reel\/(\d+)/.exec(url.pathname)?.[1] || "",
        referer: url.href
      };
      const assets2 = observed4.slice(-4).flatMap((node) => assetsFrom(node, meta));
      if (!assets2.length) {
        const ogVideo = metaContent("og:video:url", "og:video:secure_url", "og:video");
        if (ogVideo) {
          assets2.push({ ...meta, kind: "direct", url: ogVideo, extension: "mp4", label: "Video · MP4" });
        }
      }
      return assets2;
    }
  });

  // src/platforms/generic.js
  function titleOf() {
    return metaContent("og:title", "twitter:title") || document.title || "media";
  }
  __name(titleOf, "titleOf");
  function labelFor(kind, url, extra = "") {
    const noun = kind === "hls" ? "HLS stream" : kind === "dash" ? "DASH stream" : kind === "image" ? "Image" : kind === "audio" ? "Audio" : "Video";
    const ext = guessExtension(url).toUpperCase();
    const informative = ext && ext.toLowerCase() !== noun.toLowerCase();
    return [noun, informative ? ext : "", extra].filter(Boolean).join(" · ");
  }
  __name(labelFor, "labelFor");
  function fromElements() {
    const out = [];
    const meta = { platform: "page", title: titleOf(), referer: location.href };
    for (const media of document.querySelectorAll("video, audio")) {
      const sources = [media.currentSrc, media.src, ...[...media.querySelectorAll("source")].map((s) => s.src)];
      for (const src of sources) {
        if (!src || src.startsWith("blob:") || src.startsWith("data:")) continue;
        out.push({
          ...meta,
          kind: media.tagName === "AUDIO" ? "direct" : "direct",
          url: src,
          extension: guessExtension(src),
          width: media.videoWidth || 0,
          height: media.videoHeight || 0,
          duration: Number.isFinite(media.duration) ? media.duration : 0,
          label: labelFor(media.tagName === "AUDIO" ? "audio" : "video", src, "from player")
        });
      }
    }
    return out;
  }
  __name(fromElements, "fromElements");
  function fromImages() {
    const meta = { platform: "page", title: titleOf(), referer: location.href };
    const out = [];
    for (const img of document.images) {
      const src = img.currentSrc || img.src;
      if (!src || src.startsWith("data:")) continue;
      if (img.naturalWidth < 320 || img.naturalHeight < 320) continue;
      out.push({
        ...meta,
        kind: "image",
        url: src,
        extension: guessExtension(src),
        width: img.naturalWidth,
        height: img.naturalHeight,
        label: labelFor("image", src, `${img.naturalWidth}×${img.naturalHeight}`)
      });
    }
    return out.slice(0, 40);
  }
  __name(fromImages, "fromImages");
  register({
    id: "generic",
    name: "This page",
    // Always last in the registry, and only reached when nothing else matched.
    matches() {
      return true;
    },
    observe() {
      bus.on(EVENTS.MEDIA_SEEN, (event) => {
        if (!event.url) return;
        const kind = event.kind === "hls" ? "hls" : event.kind === "dash" ? "dash" : event.kind === "image" ? "image" : "direct";
        publishAssets([
          {
            platform: "page",
            kind,
            title: titleOf(),
            referer: location.href,
            [kind === "hls" || kind === "dash" ? "manifestUrl" : "url"]: event.url,
            extension: kind === "hls" || kind === "dash" ? "mp4" : guessExtension(event.url, event.mime),
            mime: event.mime,
            size: event.size,
            label: labelFor(kind, event.url, "seen on the network")
          }
        ]);
      });
    },
    async collect() {
      return [...fromElements(), ...fromImages()];
    }
  });

  // src/main.js
  var observed5 = /* @__PURE__ */ new Set();
  async function activateInline(button, host, anchor, event) {
    setButtonBusy(button, true);
    try {
      const resolved = anchor.resolve ? await anchor.resolve(host) : null;
      const assets2 = resolved ? resolved.map((asset, index) => ({ id: `${anchor.id}-${index}`, ...asset })) : getAssets();
      if (!assets2.length) {
        openWithNotice(
          "Nothing downloadable found for this item yet.",
          "Start the video, or open the post on its own page, then try again."
        );
        return;
      }
      const wantsChooser = event?.wantsChooser || event?.altKey || event?.shiftKey || event?.ctrlKey || event?.metaKey;
      if (wantsChooser) openWithAssets(assets2);
      else await downloadBest(assets2);
    } catch (err) {
      log.warn("inline resolve failed", err);
      openWithNotice("Could not read this item.", String(err?.message || err));
    } finally {
      setButtonBusy(button, false);
    }
  }
  __name(activateInline, "activateInline");
  var collecting = false;
  var collectQueued = false;
  async function collect() {
    if (collecting) {
      collectQueued = true;
      return;
    }
    collecting = true;
    try {
      const url = new URL(location.href);
      const adapter = adapterFor(url);
      if (!adapter) return;
      if (!observed5.has(adapter.id)) {
        observed5.add(adapter.id);
        try {
          adapter.observe?.({ url, document, publish: publishAssets });
        } catch (err) {
          log.warn(`adapter ${adapter.id} observe() failed; continuing without it`, err);
        }
        if (adapter.inline?.length) {
          startInlineButtons(adapter.inline, activateInline);
          setInlineActive(true);
        }
      }
      const assets2 = await adapter.collect({ url, document, publish: publishAssets });
      if (assets2?.length) {
        publishAssets(assets2.map((asset) => ({ platform: adapter.id, ...asset })));
        return;
      }
      if (adapter.id !== "generic") {
        const generic = listAdapters().find((a) => a.id === "generic");
        const fallback = await generic?.collect({ url, document, publish: publishAssets });
        if (fallback?.length) publishAssets(fallback);
      }
    } catch (err) {
      log.warn("collection failed", err);
    } finally {
      collecting = false;
      if (collectQueued) {
        collectQueued = false;
        queueMicrotask(collect);
      }
    }
  }
  __name(collect, "collect");
  function watchNavigation(onChange) {
    let current = location.href;
    const fire = /* @__PURE__ */ __name(() => {
      if (location.href === current) return;
      current = location.href;
      onChange();
    }, "fire");
    for (const method of ["pushState", "replaceState"]) {
      const original = history[method];
      history[method] = function(...args) {
        const result = original.apply(this, args);
        setTimeout(fire, 0);
        return result;
      };
    }
    addEventListener("popstate", () => setTimeout(fire, 0));
    addEventListener("pageshow", (event) => {
      if (event.persisted) onChange();
    });
  }
  __name(watchNavigation, "watchNavigation");
  function scheduleCollect() {
    resetSeen();
    clearAssets();
    clearInlineButtons();
    collect();
    setTimeout(collect, 1200);
  }
  __name(scheduleCollect, "scheduleCollect");
  async function boot() {
    await loadSettings();
    setLogLevel(get("logLevel"));
    applySettings();
    log.info(`${scriptInfo.name} ${scriptInfo.version} on ${scriptInfo.handler} ${scriptInfo.handlerVersion}`);
    startSniffer();
    initPanel();
    bus.on(EVENTS.NAVIGATED, () => collect());
    watchNavigation(scheduleCollect);
    gm.registerMenuCommand?.("Open Media Grabber — show panel", () => togglePanel(), {
      id: "omg-toggle"
    });
    gm.registerMenuCommand?.("Open Media Grabber — rescan", () => scheduleCollect(), {
      id: "omg-rescan"
    });
    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", collect, { once: true });
    } else {
      collect();
    }
    addEventListener("load", () => setTimeout(collect, 800), { once: true });
  }
  __name(boot, "boot");
  boot().catch((err) => {
    console.error("[Open Media Grabber] startup failed:", err);
  });
})();