Netflix Debug Overlay

Click the button or press Ctrl+Shift+Alt+D to activate. Read-only, does not modify Netflix in any way.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Netflix Debug Overlay
// @namespace    https://github.com/nicopasla/Netflix-Debug-Overlay
// @version      1.0.0
// @license      MIT
// @author       nicopasla
// @description  Click the button or press Ctrl+Shift+Alt+D to activate. Read-only, does not modify Netflix in any way.
// @match        https://www.netflix.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const POLL_PLAYING = 1000;
  const POLL_PAUSED  = 3000;

  const styleEl = document.createElement('style');
  styleEl.textContent = `
    #nfdo {
      position: fixed;
      z-index: 2147483647;
      top: 50%;
      left: 16px;
      transform: translateY(-50%);
      width: 260px;
      padding: 8px 11px;
      background: rgba(0,0,0,0.85);
      border-radius: 6px;
      pointer-events: none;
      user-select: none;
      font: 13px/2 'Netflix Sans', system-ui, -apple-system, sans-serif;
      color: #fff;
    }
    #nfdo hr {
      border: none;
      border-top: 1px solid rgba(255,255,255,0.1);
      margin: 4px 0;
    }
    #nfdo .row { display: flex; justify-content: space-between; }
    #nfdo .row span:last-child {
      font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
      font-size: 12px;
    }
    #nfdo .lbl { color: rgba(255,255,255,0.5); }
    #nfdo .playing { color: #4caf50; }
    #nfdo .paused  { color: #ff9800; }
    #nfdo-activate {
      position: fixed;
      z-index: 2147483647;
      top: 16px;
      left: 50%;
      transform: translateX(-50%);
      padding: 5px 18px;
      background: rgba(0,0,0,0.85);
      border: 1px solid rgba(255,255,255,0.15);
      border-radius: 4px;
      color: #fff;
      font: 13px/1.6 'Netflix Sans', system-ui, -apple-system, sans-serif;
      cursor: pointer;
    }
    #nfdo-activate:hover { background: rgba(255,255,255,0.12); }
    #nfdo .topbar {
      display: flex;
      justify-content: space-between;
      margin-bottom: 4px;
    }
    #nfdo .tbtn {
      font-size: 11px;
      color: rgba(255,255,255,0.3);
      cursor: pointer;
      pointer-events: all;
      line-height: 1;
    }
    #nfdo .tbtn:hover { color: #fff; }
    #nfdo .sec {
      font-size: 10px;
      font-weight: 700;
      letter-spacing: .1em;
      text-transform: uppercase;
      color: #5bc8d4 !important;
      margin: 2px 0 0;
    }
  `;
  document.head.appendChild(styleEl);

  const row = (lbl, val) =>
    `<div class="row"><span class="lbl">${lbl}</span><span>${val}</span></div>`;

  const sectionLabel = label => `<div class="sec">${label}</div>`;

  function fireShortcut() {
    ['keydown', 'keyup'].forEach(type =>
      document.dispatchEvent(new KeyboardEvent(type, {
        key: 'D', code: 'KeyD', keyCode: 68,
        ctrlKey: true, shiftKey: true, altKey: true,
        bubbles: true, cancelable: true,
      }))
    );
  }

  function getDebugTextarea() {
    for (const ta of document.querySelectorAll('textarea[readonly]'))
      if (ta.value.toLowerCase().includes('playing bitrate')) return ta;
    return null;
  }

  function hideNetflixPanel(ta) {
    let el = ta.parentElement;
    while (el && el !== document.body) {
      const pos = getComputedStyle(el).position;
      if (pos === 'absolute' || pos === 'fixed') {
        el.style.setProperty('display', 'none', 'important');
        return;
      }
      el = el.parentElement;
    }
    ta.parentElement.style.setProperty('display', 'none', 'important');
  }

  function parse(text) {
    const kv = {};
    for (const line of text.split('\n')) {
      const i = line.indexOf(':');
      if (i === -1) continue;
      const key = line.slice(0, i).trim();
      const val = line.slice(i + 1).trim();
      if (key && val) kv[key] = val;
    }
    const get = k => kv[k] ?? null;

    let aBr = null, vBr = null, res = null;
    const brRaw = get('Playing bitrate (a/v)');
    if (brRaw) {
      const m = brRaw.match(/(\d+)\s*\/\s*(\d+)(?:\s*\((\d+x\d+)\))?/);
      if (m) { aBr = +m[1]; vBr = +m[2]; res = m[3] || null; }
    }

    let vBrBuf = null;
    const brBufRaw = get('Buffering bitrate (a/v)');
    if (brBufRaw) {
      const m = brBufRaw.match(/\d+\s*\/\s*(\d+)/);
      if (m) vBrBuf = +m[1];
    }

    let bufA = null, bufV = null;
    const bufRaw = get('Buffer size in Seconds (a/v)');
    if (bufRaw) {
      const m = bufRaw.match(/([\d.]+)\s*\/\s*([\d.]+)/);
      if (m) { bufA = +m[1]; bufV = +m[2]; }
    }

    const tpRaw = get('Throughput');
    const throughput = tpRaw ? parseInt(tpRaw.match(/\d+/)?.[0]) : null;

    let cdn = null;
    const cdnRaw = get('Current CDN (a/v/t)');
    if (cdnRaw) {
      const m = cdnRaw.match(/([a-z0-9._-]+\.[a-z]{2,})/i);
      if (m) cdn = m[1];
    }

    let aCodec = null, aCh = null, aLang = null;
    const aRaw = get('Audio Track');
    if (aRaw) {
      const lang = aRaw.match(/^([a-z]{2,3})\b/i);
      const ch   = aRaw.match(/Channels:\s*([\d.]+)/i);
      const ac   = aRaw.match(/\(([^)]+)\)\s*$/);
      aLang  = lang ? lang[1].toLowerCase() : null;
      aCh    = ch   ? ch[1] : null;
      aCodec = ac   ? ac[1] : null;
    }

    let vmafP = null, vmafB = null;
    const vmafRaw = get('Playing/Buffering vmaf');
    if (vmafRaw) {
      const m = vmafRaw.match(/(\d+)\s*\/\s*(\d+)/);
      if (m) { vmafP = +m[1]; vmafB = +m[2]; }
    }

    let vCodec = null;
    const vRaw = get('Video Track');
    if (vRaw) {
      const m = vRaw.match(/codecs=([^\s;,]+)(?:\s*\(([^)]+)\))?/);
      if (m) vCodec = m[2] ? `${m[1]} (${m[2]})` : m[1];
    }

    const ksRaw = get('KeySystem');
    let ks = null;
    if (ksRaw) {
      const r = ksRaw.toLowerCase();
      if      (r.includes('apple.fps'))  ks = 'FairPlay';
      else if (r.includes('playready'))  { const v = ksRaw.match(/(\d+)$/); ks = v ? `PlayReady ${v[1]}` : 'PlayReady'; }
      else if (r.includes('widevine'))   { const v = ksRaw.split('.').pop(); ks = v.replace(/_/g, ' ').toLowerCase(); }
      else                               ks = ksRaw.split('.').pop();
    }

    let keyRes = null;
    const ksStatus = get('KeyStatus');
    if (ksStatus) {
      const m = ksStatus.match(/,\s*((?:\d+,?\s*)+),\s*(?:usable|expired|output-restricted)/i);
      if (m) keyRes = m[1].trim().replace(/\s*,\s*/g, '/');
    }

    const hdrRaw = get('HDR support');
    let hdr = null, hdrType = null;
    if (hdrRaw) {
      hdr = hdrRaw.toLowerCase().startsWith('true');
      const hm = hdrRaw.match(/\(([^)]+)\)/);
      const noise = ['non-hdr-display', 'is-type-supported'];
      const raw = hm ? hm[1].toLowerCase() : null;
      hdrType = raw && !noise.includes(raw) ? raw : null;
    }

    return {
      aBr, vBr, res, vBrBuf, bufA, bufV, throughput, vmafP, vmafB,
      cdn, aCodec, aCh, aLang, vCodec,
      framerate:   get('Framerate'),
      totalF:      get('Total Frames'),
      droppedF:    get('Total Dropped Frames'),
      renderState: get('Rendering state'),
      pos:         get('Position') != null ? parseFloat(get('Position')) : null,
      dur:         get('Duration') != null ? parseFloat(get('Duration')) : null,
      volume:      get('Volume'),
      hdr, hdrType, ks, keyRes,
    };
  }

  const fmtBr = kbps => kbps == null ? '—' : `${kbps} kb/s`;

  function fmtTime(s) {
    if (s == null) return '—';
    const h = Math.floor(s / 3600);
    const m = Math.floor((s % 3600) / 60);
    const secs = Math.floor(s % 60);
    return h > 0
      ? `${h}:${String(m).padStart(2,'0')}:${String(secs).padStart(2,'0')}`
      : `${m}:${String(secs).padStart(2,'0')}`;
  }

  const CDN_REGIONS = {
    ams: 'Amsterdam', bru: 'Brussels', cdg: 'Paris', lhr: 'London',
    fra: 'Frankfurt', mad: 'Madrid', mxp: 'Milan', arn: 'Stockholm',
    dub: 'Dublin', waw: 'Warsaw', atl: 'Atlanta', iad: 'Washington',
    lax: 'Los Angeles', jfk: 'New York', ord: 'Chicago', dfw: 'Dallas',
    sea: 'Seattle', mia: 'Miami', sfo: 'San Francisco', yyz: 'Toronto',
    nrt: 'Tokyo', hkg: 'Hong Kong', sin: 'Singapore', syd: 'Sydney',
    gru: 'São Paulo', bog: 'Bogotá', scl: 'Santiago',
  };
  function cdnRegion(cdn) {
    if (!cdn) return null;
    const m = cdn.match(/\b([a-z]{3})\d{3}\b/);
    return m ? (CDN_REGIONS[m[1]] || m[1].toUpperCase()) : null;
  }

  let pollTimer = null;

  function setPoll(state, overlay, btn, ms) {
    if (state.currentPoll === ms) return;
    state.currentPoll = ms;
    clearInterval(pollTimer);
    pollTimer = setInterval(() => render(overlay, btn, state), ms);
  }

  function render(overlay, btn, state) {
    const ta = getDebugTextarea();

    if (!ta) {
      state.closed = false;
      btn.style.display     = 'block';
      overlay.style.display = 'none';
      return;
    }

    if (state.closed) {
      btn.style.display     = 'block';
      overlay.style.display = 'none';
      return;
    }

    btn.style.display     = 'none';
    overlay.style.display = 'block';

    hideNetflixPanel(ta);
    const d = parse(ta.value);
    const isPlaying = d.renderState !== 'Paused';

    setPoll(state, overlay, btn, isPlaying ? POLL_PLAYING : POLL_PAUSED);

    const stateCls = isPlaying ? 'playing' : 'paused';
    const stateStr = isPlaying ? 'PLAYING' : 'PAUSED';

    let html = row('State', `<span class="${stateCls}">${stateStr}</span>`);

    html += `<hr>` + sectionLabel('VIDEO');
    if (d.res)            html += row('Resolution',  d.res);
    if (d.vBr)            html += row('Bitrate',     fmtBr(d.vBr));
    if (d.vBrBuf != null) html += row('Buffering',   fmtBr(d.vBrBuf));
    if (d.bufV != null)   html += row('Buffer',      `${d.bufV.toFixed(1)}s`);
    if (d.vCodec)         html += row('Codec',       d.vCodec);
    if (d.framerate)      html += row('Frame rate',  `${d.framerate} fps`);
    if (d.vmafP != null)  html += row('VMAF p/b',    `${d.vmafP} / ${d.vmafB}`);
    if (d.hdr !== null)   html += row('HDR',         d.hdr ? `YES${d.hdrType ? ` (${d.hdrType})` : ''}` : 'NO');
    if (d.throughput && d.vBr)
      html += row('ABR usage', `${Math.round((d.vBr / d.throughput) * 100)}%`);

    html += `<hr>` + sectionLabel('AUDIO');
    html += row('Bitrate',  fmtBr(d.aBr));
    if (d.aCh)            html += row('Channels', d.aCh);
    if (d.bufA != null)   html += row('Buffer',   `${d.bufA.toFixed(1)}s`);
    if (d.aCodec)         html += row('Codec',    d.aCodec);
    if (d.aLang)          html += row('Language', d.aLang);

    html += `<hr>` + sectionLabel('NETWORK');
    if (d.throughput)     html += row('Throughput', fmtBr(d.throughput));
    if (d.cdn) {
      const region = cdnRegion(d.cdn);
      html += row('CDN', region
        ? `${d.cdn.replace('.nflxvideo.net', '')} (${region})`
        :  d.cdn.replace('.nflxvideo.net', ''));
    }
    if (d.ks)             html += row('DRM',     d.ks);
    if (d.keyRes)         html += row('DRM res', d.keyRes);

    html += `<hr>` + sectionLabel('PLAYBACK');
    if (d.totalF)              html += row('Dropped',  `${parseInt(d.droppedF) || 0} / ${d.totalF}`);
    if (d.volume)              html += row('Volume',   d.volume);
    if (d.pos != null && d.dur != null)
      html += row('Position', `${fmtTime(d.pos)} / ${fmtTime(d.dur)}`);

    overlay.innerHTML = html;

    const topBar = document.createElement('div');
    topBar.className = 'topbar';

    const mkBtn = (label, onClick) => {
      const s = document.createElement('span');
      s.className = 'tbtn';
      s.textContent = label;
      s.addEventListener('click', onClick);
      return s;
    };

    topBar.appendChild(mkBtn('copy', () => navigator.clipboard.writeText(ta.value)));

    topBar.appendChild(mkBtn('copy json', () => {
      navigator.clipboard.writeText(JSON.stringify({
        resolution:     d.res,
        video_bitrate:  d.vBr,
        audio_bitrate:  d.aBr,
        video_codec:    d.vCodec,
        audio_codec:    d.aCodec,
        audio_lang:     d.aLang,
        audio_channels: d.aCh,
        framerate:      d.framerate,
        vmaf:           d.vmafP,
        hdr:            d.hdr,
        hdr_type:       d.hdrType,
        throughput:     d.throughput,
        abr_usage:      (d.throughput && d.vBr) ? Math.round((d.vBr / d.throughput) * 100) : null,
        cdn:            d.cdn?.replace('.nflxvideo.net', '') ?? null,
        cdn_region:     cdnRegion(d.cdn),
        drm:            d.ks,
        drm_res:        d.keyRes,
        dropped_frames: parseInt(d.droppedF) || 0,
        total_frames:   d.totalF ? parseInt(d.totalF) : null,
        timestamp: new Date().toISOString(),
        debug_raw:      ta.value,
      }, null, 2));
    }));

    topBar.appendChild(mkBtn('x', () => {
      state.closed = true;
      overlay.style.display = 'none';
      btn.style.display = 'block';
    }));

    overlay.prepend(topBar);
  }

  let retryTimer = null;

  function boot() {
    if (document.getElementById('nfdo')) return true;
    if (!document.body) return false;

    const overlay = document.createElement('div');
    overlay.id = 'nfdo';
    overlay.style.display = 'none';
    document.body.appendChild(overlay);

    const btn = document.createElement('button');
    btn.id = 'nfdo-activate';
    btn.textContent = 'Show Debug';
    document.body.appendChild(btn);

    const state = { closed: false, currentPoll: POLL_PLAYING };
    pollTimer = setInterval(() => render(overlay, btn, state), POLL_PLAYING);

    btn.addEventListener('click', () => {
      state.closed = false;
      if (!getDebugTextarea()) fireShortcut();
    });
    return true;
  }

  function teardown() {
    clearInterval(pollTimer);
    clearTimeout(retryTimer);
    pollTimer = retryTimer = null;
    document.getElementById('nfdo')?.remove();
    document.getElementById('nfdo-activate')?.remove();
  }

  function tryBoot(attempts = 40) {
    if (!location.pathname.startsWith('/watch')) return;
    if (boot()) return;
    if (attempts > 0) retryTimer = setTimeout(() => tryBoot(attempts - 1), 500);
  }

  function onNavigate() {
    teardown();
    tryBoot();
  }

  const _push    = history.pushState.bind(history);
  const _replace = history.replaceState.bind(history);
  history.pushState    = (...a) => { _push(...a);    onNavigate(); };
  history.replaceState = (...a) => { _replace(...a); onNavigate(); };
  window.addEventListener('popstate', onNavigate);

  let lastPath = location.pathname;
  new MutationObserver(() => {
    if (location.pathname !== lastPath) {
      lastPath = location.pathname;
      onNavigate();
    }
  }).observe(document.documentElement, { childList: true, subtree: true });

  tryBoot();

})();