Brain.fm Automator

Enhanced audio downloader + auto-signup for Brain.fm with ID3 metadata embedding

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Brain.fm Automator
// @description  Enhanced audio downloader + auto-signup for Brain.fm with ID3 metadata embedding
// @author       Soul
// @version      3.1.0
// @match        https://my.brain.fm/*
// @match        https://my.brain.fm/player*
// @match        https://brain.fm/*
// @grant        GM_download
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-idle
// @namespace https://greasyfork.org/users/1597689
// ==/UserScript==


(function () {
  'use strict';

  // ===== CONFIGURATION =====
  const CONFIG = {
    WIDGET_ID: 'brainfm-unified-widget-fixed',
    POS_KEY: 'brainfm_widget_pos',
    META_KEY: 'brainfm_meta_enabled',
    DRAG_THRESHOLD: 3,
    OBSERVER_DEBOUNCE: 250,
    DOWNLOAD_TIMEOUT: 120000,
    STATE_CHECK_INTERVAL: 800,
    MAX_RETRY_ATTEMPTS: 3,
    METADATA_ENABLED: true, // Default: embed metadata
    SELECTORS: {
      trackTitle: '[data-testid="currentTrackTitle"]',
      trackGenre: '[data-testid="trackGenre"]',
      trackArt: "[data-testid='currentTrackInformationCard'] img.sc-6e0b0444-1",
      profileTab: '[data-testid="sideDeckProfileTab"]',
      audio: 'audio',
      source: 'source[src]'
    }
  };

  // ===== UTILITIES =====
  const $ = (sel, ctx = document) => ctx.querySelector(sel);
  const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const log = (...a) => console.log('%c[BrainFM]', 'color:#7df', ...a);
  const warn = (...a) => console.warn('%c[BrainFM]', 'color:#fa3', ...a);

  // Safe storage wrapper
  const storage = {
    get: (key, fallback) => {
      try {
        const raw = typeof GM_getValue === 'function' 
          ? GM_getValue(key) 
          : localStorage.getItem(key);
        return raw ? JSON.parse(raw) : fallback;
      } catch { return fallback; }
    },
    set: (key, val) => {
      try {
        const str = JSON.stringify(val);
        typeof GM_setValue === 'function' 
          ? GM_setValue(key, val) 
          : localStorage.setItem(key, str);
      } catch (e) { warn('Storage error:', e); }
    }
  };

  const debounce = (fn, wait) => {
    let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn.apply(null, a), wait); };
  };

  // ===== ID3 METADATA EMBEDDER (Minimal MP3 Support) =====
  const ID3Embedder = (() => {
    const writeID3 = (audioBuffer, metadata) => {
      const { title, artist, genre, coverBlob } = metadata;
      
      const processCover = async (blob) => {
        if (!blob) return null;
        const img = new Image();
        img.src = URL.createObjectURL(blob);
        await new Promise(r => img.onload = r);
        
        const maxDim = 600, quality = 0.85;
        let { width, height } = img;
        if (width > maxDim || height > maxDim) {
          const ratio = Math.min(maxDim / width, maxDim / height);
          width *= ratio; height *= ratio;
        }
        
        const canvas = Object.assign(document.createElement('canvas'), { width, height });
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, width, height);
        
        return new Promise(resolve => {
          canvas.toBlob(resolve, 'image/jpeg', quality);
          URL.revokeObjectURL(img.src);
        });
      };

      const createTextFrame = (id, text) => {
        if (!text) return null;
        const encoder = new TextEncoder();
        const encoded = encoder.encode(text);
        const frameSize = encoded.length + 1;
        return {
          id,
          data: new Uint8Array([0x00, ...encoded]),
          size: frameSize
        };
      };

      const createAPICFrame = (coverBuffer) => {
        if (!coverBuffer) return null;
        const encoder = new TextEncoder();
        const mimeType = encoder.encode('image/jpeg\0');
        const pictureType = new Uint8Array([0x03]);
        const description = encoder.encode('\0');
        
        const frameData = new Uint8Array(
          1 + mimeType.length + 1 + pictureType.length + description.length + coverBuffer.length
        );
        let offset = 0;
        frameData[offset++] = 0x00;
        frameData.set(mimeType, offset); offset += mimeType.length;
        frameData.set(pictureType, offset); offset += pictureType.length;
        frameData.set(description, offset); offset += description.length;
        frameData.set(coverBuffer, offset);
        
        return { id: 'APIC', data: frameData, size: frameData.length };
      };

      const encodeFrame = (frame) => {
        if (!frame) return null;
        const header = new Uint8Array(10);
        const encoder = new TextEncoder();
        encoder.encodeInto(frame.id, header);
        header[4] = (frame.size >> 24) & 0xFF;
        header[5] = (frame.size >> 16) & 0xFF;
        header[6] = (frame.size >> 8) & 0xFF;
        header[7] = frame.size & 0xFF;
        header[8] = 0x00; header[9] = 0x00;
        return new Uint8Array([...header, ...frame.data]);
      };

        /* eslint-disable no-async-promise-executor */
        /* eslint-disable no-unused-vars */
      return new Promise(async (resolve, reject) => {
        try {
          const frames = [];
          
          const titleFrame = createTextFrame('TIT2', title);
          const artistFrame = createTextFrame('TPE1', artist || 'Brain.fm');
          const genreFrame = createTextFrame('TCON', genre || 'Ambient');
          if (titleFrame) frames.push(encodeFrame(titleFrame));
          if (artistFrame) frames.push(encodeFrame(artistFrame));
          if (genreFrame) frames.push(encodeFrame(genreFrame));
          
          if (coverBlob) {
            const processedCover = await processCover(coverBlob);
            if (processedCover) {
              const coverBuffer = new Uint8Array(await processedCover.arrayBuffer());
              const apicFrame = createAPICFrame(coverBuffer);
              if (apicFrame) frames.push(encodeFrame(apicFrame));
            }
          }
          
          if (frames.length === 0) return resolve(audioBuffer);
          
          const tagSize = frames.reduce((sum, f) => sum + f.length, 0) + 10;
          const tagHeader = new Uint8Array(10);
          tagHeader.set([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00]);
          tagHeader[6] = (tagSize >> 21) & 0x7F;
          tagHeader[7] = (tagSize >> 14) & 0x7F;
          tagHeader[8] = (tagSize >> 7) & 0x7F;
          tagHeader[9] = tagSize & 0x7F;
          
          const newBuffer = new Uint8Array(tagSize + audioBuffer.byteLength);
          let offset = 0;
          newBuffer.set(tagHeader, offset); offset += 10;
          for (const frame of frames) {
            newBuffer.set(frame, offset); offset += frame.length;
          }
          newBuffer.set(new Uint8Array(audioBuffer), offset);
          
          resolve(newBuffer.buffer);
        } catch (e) {
          warn('ID3 embedding failed:', e);
          resolve(audioBuffer);
        }
      });
    };

    return { writeID3 };
  })();

  // ===== STYLES =====
  const createStyles = () => {
    const css = `
      #${CONFIG.WIDGET_ID} {
        position: fixed; top: 20px; right: 20px; z-index: 2147483647;
        display: flex; flex-direction: column; gap: 8px;
        padding: 12px 14px; border-radius: 16px;
        border: 1px solid rgba(255,255,255,0.25);
        background: rgba(255,255,255,0.08);
        backdrop-filter: blur(30px) saturate(180%) brightness(1.25);
        -webkit-backdrop-filter: blur(30px) saturate(180%) brightness(1.25);
        box-shadow: inset 0 0 0 1px rgba(255,255,255,0.35), 0 8px 32px rgba(0,0,0,0.35);
        color: #fff; font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
        cursor: grab; user-select: none; min-width: 190px; max-width: 44vw;
        transition: all 0.25s ease; will-change: transform;
      }
      #${CONFIG.WIDGET_ID}:hover {
        background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.4);
        box-shadow: inset 0 0 0 1.2px rgba(255,255,255,0.5), 0 10px 36px rgba(0,0,0,0.45);
        transform: translateY(-1px);
      }
      #${CONFIG.WIDGET_ID}.dragging { cursor: grabbing; transition: none; }
      #${CONFIG.WIDGET_ID}.error { animation: errflash 0.9s ease; }
      #${CONFIG.WIDGET_ID} .row { display: flex; gap: 8px; align-items: center; }
      #${CONFIG.WIDGET_ID} .primary-btn {
        all: unset; cursor: pointer; flex: 1; padding: 8px 12px; text-align: center;
        border-radius: 10px; background: linear-gradient(145deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05));
        border: 1px solid rgba(255,255,255,0.35); color: #fff; font-weight: 600;
        backdrop-filter: blur(16px); transition: all 0.2s ease;
      }
      #${CONFIG.WIDGET_ID} .primary-btn:hover:not([disabled]) {
        background: linear-gradient(145deg, rgba(255,255,255,0.4), rgba(255,255,255,0.1));
        transform: translateY(-1px); box-shadow: 0 4px 14px rgba(0,0,0,0.35);
      }
      #${CONFIG.WIDGET_ID} .primary-btn[disabled] { opacity: 0.6; cursor: not-allowed; }
      #${CONFIG.WIDGET_ID} .meta {
        font-size: 12px; opacity: 0.9; overflow: hidden;
        text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
      }
      #${CONFIG.WIDGET_ID} .icon-btn {
        all: unset; width: 36px; height: 36px; display: inline-flex;
        align-items: center; justify-content: center; border-radius: 10px;
        background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.25);
        color: #fff; cursor: pointer; font-weight: 700; transition: all 0.2s ease;
      }
      #${CONFIG.WIDGET_ID} .icon-btn:hover { background: rgba(255,255,255,0.2); transform: scale(1.08); }
      #${CONFIG.WIDGET_ID} .settings-row {
        justify-content: space-between; font-size: 11px; opacity: 0.85;
        display: flex; align-items: center; gap: 6px;
      }
      #${CONFIG.WIDGET_ID} .settings-row label {
        display: flex; align-items: center; gap: 6px; cursor: pointer;
      }
      #${CONFIG.WIDGET_ID} .settings-row input[type="checkbox"] {
        cursor: pointer; accent-color: #7df;
      }
      @keyframes errflash {
        0%,100% { box-shadow: 0 8px 32px rgba(0,0,0,0.35); }
        15% { box-shadow: 0 0 0 3px rgba(255,0,0,0.55); }
      }
      @media (prefers-reduced-motion: reduce) {
        #${CONFIG.WIDGET_ID}, #${CONFIG.WIDGET_ID} * { transition: none !important; animation: none !important; }
      }
    `;
    if (typeof GM_addStyle === 'function') GM_addStyle(css);
    else { const s = document.createElement('style'); s.textContent = css; document.head.appendChild(s); }
  };

  // ===== WIDGET MANAGER =====
  const Widget = (() => {
    let el, state = { downloading: false, dragging: false, audioSrc: null };

    const create = () => {
      if (document.getElementById(CONFIG.WIDGET_ID)) return;
      
      el = document.createElement('div');
      el.id = CONFIG.WIDGET_ID;
      el.innerHTML = `
        <div class="meta" id="meta-display">Initializing...</div>
        <div class="row">
          <button class="primary-btn" id="btn-download">🎧 Download Audio</button>
          <button class="icon-btn" id="btn-refresh" title="Refresh">↻</button>
        </div>
        <div class="row">
          <button class="primary-btn" id="btn-auto">New Acc</button>
      `;
      document.body.appendChild(el);
      return el;
    };

    const restorePosition = () => {
      const pos = storage.get(CONFIG.POS_KEY, null);
      if (!pos?.left || !pos?.top) return;
      const { left, top } = pos;
      const maxL = window.innerWidth - 200, maxT = window.innerHeight - 100;
      el.style.cssText += `left:${Math.max(4, Math.min(left, maxL))}px;top:${Math.max(4, Math.min(top, maxT))}px;right:auto;bottom:auto;`;
    };

    const savePosition = () => {
      const r = el.getBoundingClientRect();
      storage.set(CONFIG.POS_KEY, { left: r.left, top: r.top });
    };

    const setupDrag = () => {
      let startX, startY, origX, origY, moved = false;

      const onDown = (e) => {
        if (e.button !== 0 || e.target.closest('button')) return;
        e.preventDefault();
        const rect = el.getBoundingClientRect();
        startX = e.clientX; startY = e.clientY;
        origX = rect.left; origY = rect.top;
        moved = false;
        state.dragging = true;
        el.classList.add('dragging');
        el.setPointerCapture?.(e.pointerId);
        document.addEventListener('pointermove', onMove);
        document.addEventListener('pointerup', onUp);
      };

      const onMove = (e) => {
        if (!state.dragging) return;
        const dx = e.clientX - startX, dy = e.clientY - startY;
        if (!moved && (Math.abs(dx) > CONFIG.DRAG_THRESHOLD || Math.abs(dy) > CONFIG.DRAG_THRESHOLD)) {
          moved = true; el.style.transition = 'none';
        }
        if (!moved) return;
        const maxL = window.innerWidth - el.offsetWidth - 4;
        const maxT = window.innerHeight - el.offsetHeight - 4;
        el.style.left = `${Math.max(4, Math.min(origX + dx, maxL))}px`;
        el.style.top = `${Math.max(4, Math.min(origY + dy, maxT))}px`;
        el.style.right = 'auto'; el.style.bottom = 'auto';
      };

      const onUp = (e) => {
        if (!state.dragging) return;
        state.dragging = false;
        el.classList.remove('dragging');
        el.style.transition = '';
        el.releasePointerCapture?.(e.pointerId);
        document.removeEventListener('pointermove', onMove);
        document.removeEventListener('pointerup', onUp);
        if (moved) savePosition();
      };

      el.addEventListener('pointerdown', onDown);
      el.addEventListener('dragstart', e => e.preventDefault());
    };

    const updateState = (info) => {
      const meta = $('#meta-display', el);
      const btn = $('#btn-download', el);
      if (!meta || !btn) return;
      
      if (info?.title) {
        const display = info.title.length > 30 ? info.title.slice(0, 27) + '...' : info.title;
        meta.textContent = display;
        meta.title = info.title;
      }
      btn.disabled = state.downloading || !info?.src;
      btn.textContent = state.downloading ? '⏳ Processing...' : '🎧 Download Audio';
    };

    const flashError = () => {
      el.classList.add('error');
      setTimeout(() => el.classList.remove('error'), 900);
    };

    return { create, restorePosition, setupDrag, updateState, flashError, getEl: () => el, getState: () => state, setState: (s) => Object.assign(state, s) };
  })();

// ===== AUDIO HANDLER =====
const decodeTrackName = (str) => {
  if (!str) return '';
  return str
    .replace(/([a-z])([A-Z])/g, '$1 $2')
    .replace(/_/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
};

const AudioHandler = (() => {
  const listened = new WeakSet();
  let lastValidInfo = null;
  let currentPageType = null;

  const sanitize = (name) => (name || 'audio').replace(/[/\\?%*:|"<>]/g, '_').trim() || 'audio';
  
  const getExt = (url, ct) => {
    const urlExt = url?.match(/\.(\w+)(?:[?#]|$)/i)?.[1]?.toLowerCase();
    if (urlExt && ['mp3','wav','ogg','m4a','aac','flac','opus'].includes(urlExt)) return urlExt;
    const ctMap = {
      'audio/mpeg':'mp3','audio/mp3':'mp3','audio/ogg':'ogg','audio/opus':'opus',
      'audio/aac':'aac','audio/wav':'wav','audio/x-wav':'wav','audio/flac':'flac',
      'audio/mp4':'m4a','audio/m4a':'m4a'
    };
    return ctMap[ct?.toLowerCase()] || 'mp3';
  };
/* eslint-disable no-empty */
  const parseCD = (cd) => {
    if (!cd) return '';
    const m1 = cd.match(/filename\*\s*=\s*[^']*'[^']*'([^;]+)/i);
    if (m1) { try { return decodeURIComponent(m1[1]); } catch {} }
    const m2 = cd.match(/filename\s*=\s*"?([^";]+)"?/i);
    return m2?.[1] || '';
  };

  const getHeader = (headers, key) => {
    if (!headers) return '';
    const k = key.toLowerCase();
    return headers.split(/\r?\n/).find(l => l.toLowerCase().startsWith(k + ':'))?.split(':', 2)[1]?.trim() || '';
  };

  // 🎨 NEW: Extract track cover art URL
  const getTrackArtUrl = () => {
    // Primary: Player page card image
    const cardImg = $(CONFIG.SELECTORS.trackArt);
    if (cardImg?.src && cardImg.src.includes('unsplash.com')) {
      return cardImg.src.replace('&w=1080', '&w=1440'); // Higher res
    }
    // Fallback: Mini-player on homepage (styled-components class)
    const miniImg = $('.bMnsXL ~ div img') || $('[class*="currentTrack"] img');
    if (miniImg?.src) return miniImg.src;
    return null;
  };

  const getTrackInfo = () => {
    let title = null, genre = null;
    
    if (location.pathname.startsWith('/player')) {
      title = $("[data-testid='currentTrackTitle']")?.textContent?.trim();
      genre = $("[data-testid='trackGenre']")?.textContent?.trim();
    }
    
    if (!title) {
      const titleEl = $('.bMnsXL');
      const genreEl = $('[class*="gustOw"][color]');
      if (titleEl?.textContent?.trim()) title = titleEl.textContent.trim();
      if (genreEl?.textContent?.trim()) genre = genreEl.textContent.trim();
    }
    
    if (!title) {
      const audio = $$('audio').find(a => a.src || a.currentSrc);
      if (audio) {
        const src = audio.src || audio.currentSrc;
        const filename = src?.split('/').pop()?.split('?')[0] || '';
        if (filename) {
          const parts = filename.replace('.mp3', '').split('_');
          if (parts.length >= 2) {
            title = parts[0].replace(/([a-z])([A-Z])/g, '$1 $2').trim();
            const possibleGenre = parts.find(p => 
              !/^\d+$/.test(p) && !p.includes('BPM') && !p.includes('VPR')
            );
            if (possibleGenre && possibleGenre !== parts[0]) {
              genre = possibleGenre.charAt(0).toUpperCase() + possibleGenre.slice(1);
            }
          }
        }
      }
    }
    
    if (title) return { title: genre ? `${title} - ${genre}` : title, genre };
    return null;
  };

  const findAudio = () => {
    const candidates = $$('audio').map(audio => {
      let src = audio.src || audio.currentSrc;
      if (!src) {
        const s = $('source[src]', audio);
        if (s) src = s.src || s.getAttribute('src');
      }
      return src ? { audio, src: new URL(src, location.href).href } : null;
    }).filter(Boolean);

    const playing = candidates.find(c => !c.audio.paused && c.audio.currentTime > 0);
    return playing || candidates[0] || null;
  };

  const attachListeners = (audio) => {
    if (listened.has(audio)) return;
    listened.add(audio);
    const update = debounce(() => {
      const info = buildAudioInfo();
      if (info) Widget.updateState(info);
    }, 100);
    
    ['loadedmetadata','canplay','play','pause','ended','emptied','stalled','suspend','error'].forEach(ev => 
      audio.addEventListener(ev, update, { passive: true })
    );
    $$('source[src]', audio).forEach(s => 
      ['load','error'].forEach(ev => s.addEventListener(ev, update, { passive: true }))
    );
  };

  const buildAudioInfo = () => {
    const item = findAudio();
    if (!item) {
      if (lastValidInfo) return { ...lastValidInfo, title: lastValidInfo.title + ' (paused)' };
      return null;
    }
    
    attachListeners(item.audio);
    const track = getTrackInfo();
    const ext = getExt(item.src);
    
    const urlBase = item.src.split('/').pop()?.split('?')[0] || 'audio';
    const base = sanitize(track?.title || decodeTrackName(urlBase.replace(/\.[^.]+$/, '')));
    const filename = `${base}.${ext}`;
    
    const info = { 
      src: item.src, 
      title: track?.title || decodeTrackName(urlBase), 
      filename,
      genre: track?.genre,
      coverUrl: getTrackArtUrl() // 🎨 Include cover URL
    };
    
    if (track?.title) lastValidInfo = info;
    return info;
  };

  const scan = debounce(() => {
    const info = buildAudioInfo();
    if (info) {
      Widget.updateState(info);
    } else if (!lastValidInfo) {
      Widget.updateState({ title: 'No audio detected' });
    }
  }, CONFIG.OBSERVER_DEBOUNCE);

  const initObserver = () => {
    const mo = new MutationObserver(scan);
    mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
    
    let lastURL = location.href;
    const urlObserver = setInterval(() => {
      if (location.href !== lastURL) {
        lastURL = location.href;
        currentPageType = null;
        scan();
      }
    }, 500);
    
    document.addEventListener('visibilitychange', () => { 
      if (document.visibilityState === 'visible') scan(); 
    });
    
    window.addEventListener('beforeunload', () => clearInterval(urlObserver));
    
    return scan;
  };

  return { buildAudioInfo, initObserver, getExt, sanitize, parseCD, getHeader, getTrackArtUrl, getTrackInfo };
})();

  // ===== DOWNLOADER =====
  const Downloader = (() => {
    const gmXhr = (opts) => {
      if (typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest(opts);
      if (typeof GM?.xmlHttpRequest === 'function') return GM.xmlHttpRequest(opts);
      throw new Error('GM_xhr unavailable');
    };

    const downloadBlob = (blob, filename, type) => {
      const url = URL.createObjectURL(blob);
      const a = Object.assign(document.createElement('a'), { href: url, download: filename, rel: 'noopener' });
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 100);
    };

    // 🎨 UPDATED: viaGMXhr now accepts metadata parameter
    const viaGMXhr = async (url, filename, metadata = {}) => {
      return new Promise((resolve, reject) => {
        gmXhr({
          method: 'GET', url, responseType: 'arraybuffer',
          headers: { Referer: location.href, Accept: 'audio/*', 'Cache-Control': 'no-cache' },
          timeout: CONFIG.DOWNLOAD_TIMEOUT,
          onload: async (r) => {
            if (!r.response) return reject('Empty response');
            
            let blob = new Blob([new Uint8Array(r.response)], { 
              type: AudioHandler.getHeader(r.responseHeaders, 'content-type') || 'audio/mpeg' 
            });
            
            // 🎨 Embed metadata if enabled, MP3 format, and metadata provided
            const isMp3 = filename.toLowerCase().endsWith('.mp3');
            if (CONFIG.METADATA_ENABLED && isMp3 && (metadata.title || metadata.coverUrl)) {
              try {
                let coverBlob = null;
                
                // Fetch cover art if URL provided
                if (metadata.coverUrl) {
                  coverBlob = await new Promise((res, rej) => {
                    gmXhr({
                      url: metadata.coverUrl,
                      responseType: 'blob',
                      headers: { Referer: location.href },
                      onload: (imgResp) => res(imgResp.response),
                      onerror: rej,
                      timeout: 15000
                    });
                  });
                  log('🖼️ Cover art fetched');
                }
                
                // Embed ID3 tags
                const taggedBuffer = await ID3Embedder.writeID3(r.response, {
                  title: metadata.title,
                  artist: 'Brain.fm',
                  genre: metadata.genre,
                  coverBlob
                });
                
                blob = new Blob([taggedBuffer], { type: 'audio/mpeg' });
                log('✅ Metadata embedded successfully');
              } catch (e) {
                warn('⚠️ Metadata embedding skipped:', e);
                // Continue with original blob - don't fail the download
              }
            }
            
            downloadBlob(blob, filename, blob.type);
            resolve();
          },
          onerror: reject, ontimeout: reject
        });
      });
    };

    const viaGMDownload = (url, filename, metadata = {}) => new Promise((resolve, reject) => {
      if (typeof GM_download !== 'function') return reject('GM_download unavailable');
      // Note: GM_download doesn't support post-processing, so metadata won't be embedded
      GM_download({
        url, name: filename, saveAs: true,
        onload: resolve, onerror: reject, ontimeout: reject, timeout: CONFIG.DOWNLOAD_TIMEOUT
      });
    });

    const viaAnchor = (url, filename) => {
      const a = Object.assign(document.createElement('a'), {
        href: url, download: filename, rel: 'noopener', target: '_blank'
      });
      document.body.appendChild(a); a.click(); a.remove();
    };

    // 🎨 UPDATED: download now accepts metadata parameter
    const download = async (url, filename, metadata = {}) => {
      // Try methods in order of reliability
      // Prefer GM_xhr for metadata embedding support
      try { await viaGMXhr(url, filename, metadata); return true; } catch {}
      try { await viaGMDownload(url, filename, metadata); return true; } catch {}
      viaAnchor(url, filename); return true; // Fallback (no metadata)
    };

    return { download };
  })();

// ===== SIGNUP AUTOMATION =====
  const AutoSignup = (() => {
    const find = (textRe, tag = '*') => 
      $$(tag).find(el => textRe.test(el.textContent?.trim() || el.getAttribute('aria-label') || ''));

    const fillInput = (sel, val) => {
      const el = $(sel); if (!el) return false;
      const proto = Object.getPrototypeOf(el);
      const desc = Object.getOwnPropertyDescriptor(proto, 'value');
      if (desc?.set) desc.set.call(el, val); else el.value = val;
      el.dispatchEvent(new Event('input', { bubbles: true }));
      el.dispatchEvent(new Event('change', { bubbles: true }));
      return true;
    };

    const clickEl = async (el) => {
      if (!el) return false;
      el.scrollIntoView({ behavior: 'auto', block: 'center' });
      await sleep(200);
      ['mouseover','mousedown','mouseup','click'].forEach(ev => 
        el.dispatchEvent(new MouseEvent(ev, { bubbles: true, cancelable: true }))
      );
      return true;
    };

    const waitFor = async (sel, timeout = 10000) => {
      const start = Date.now();
      while (Date.now() - start < timeout) {
        const el = $(sel); if (el) return el;
        await sleep(300);
      }
      return null;
    };

    const handleConfirmationModal = async () => {
      const modal = await waitFor('[role="dialog"], .modal, [class*="modal"]', 3000);
      if (!modal) return false;

      const origConfirm = window.confirm;
      window.confirm = () => true;

      const okBtn = $$('button', modal).find(btn => 
        /^(ok|yes|confirm|log out|logout)$/i.test(btn.textContent?.trim() || '')
      );

      if (okBtn) {
        await clickEl(okBtn);
        await sleep(500);
        window.confirm = origConfirm;
        return true;
      }

      const firstBtn = $('button', modal);
      if (firstBtn && !/cancel|no|back/i.test(firstBtn.textContent?.trim() || '')) {
        await clickEl(firstBtn);
        await sleep(500);
        window.confirm = origConfirm;
        return true;
      }

      window.confirm = origConfirm;
      return false;
    };

    const logoutFlow = async () => {
      const openBtn = find(/open/i); if (!openBtn) return false;
      await clickEl(openBtn); await sleep(1500);
      
      const profile = await waitFor(CONFIG.SELECTORS.profileTab); if (!profile) return false;
      await clickEl(profile); await sleep(2000);
      
      const logout = $$('button,div,span').find(el => 
        el.textContent?.trim().toLowerCase() === 'logout' && el.offsetParent
      );
      
      if (logout) {
        await clickEl(logout);
        await sleep(500);
        await handleConfirmationModal();
        await sleep(1500);
        return true;
      }
      return false;
    };

    const signupFlow = async () => {
      if (!await waitFor('input[type="email"]', 15000)) { warn('Signup form timeout'); return; }
      
      const ts = Date.now(), rand = Math.random().toString(36).slice(2,7);
      const creds = {
        email: `user_${ts}@autotest.com`,
        username: `user_${rand}`,
        password: Math.random().toString(36).slice(2,10) + '!'
      };
      
      fillInput('input[id*="name"],input[name*="name"]', creds.username);
      fillInput('input[id*="email"],input[name*="email"]', creds.email);
      fillInput('input[id*="password"],input[name*="password"]', creds.password);
      log('📝 Filled:', creds.email);
      
      const submit = find(/create|sign\s*up/i, 'button');
      if (submit) { await clickEl(submit); log('✅ Submitted'); }
      else { warn('⚠️ Submit button not found'); }
      
      const check = setInterval(() => {
        if (location.href.includes('/welcome')) {
          clearInterval(check);
          const btn = $('#btn-auto'); if (btn) { btn.style.background = '#00c851'; btn.textContent = '✓ Success'; }
          log('🎉 Signup complete');
        }
      }, 800);
      setTimeout(() => clearInterval(check), 30000);
    };

    const run = async () => {
      const btn = Widget.getEl()?.querySelector('#btn-auto');
      if (!btn) return;
      
      btn.disabled = true; btn.style.opacity = '0.7';
      try {
        if (location.href.includes('/welcome')) {
          btn.style.background = '#00c851'; btn.textContent = '✓ Active';
          log('✅ Already on welcome page');
          return;
        }
        
        const didLogout = await logoutFlow();
        if (didLogout) await sleep(2000);
        await signupFlow();
      } catch (e) { warn('Auto-signup error:', e); }
      finally {
        btn.disabled = false; btn.style.opacity = '1';
        if (!location.href.includes('/welcome')) btn.textContent = 'New Acc';
      }
    };

    return { run };
  })();

  // ===== INITIALIZATION =====
  const init = () => {
    // Restore metadata toggle preference
    CONFIG.METADATA_ENABLED = storage.get(CONFIG.META_KEY, true);
    
    createStyles();
    Widget.create();
    Widget.restorePosition();
    Widget.setupDrag();
    
    // 🎨 Setup metadata toggle
    const metaToggle = $('#meta-toggle');
    if (metaToggle) {
      metaToggle.checked = CONFIG.METADATA_ENABLED;
      metaToggle.addEventListener('change', (e) => {
        CONFIG.METADATA_ENABLED = e.target.checked;
        storage.set(CONFIG.META_KEY, CONFIG.METADATA_ENABLED);
        log(`🎨 Metadata embedding: ${CONFIG.METADATA_ENABLED ? 'enabled' : 'disabled'}`);
      });
    }
    
    // Button handlers
    $('#btn-download')?.addEventListener('click', async () => {
      const { downloading } = Widget.getState();
      if (downloading) return;
      
      const info = AudioHandler.buildAudioInfo();
      if (!info?.src) { Widget.flashError(); return; }
      
      // 🎨 Prepare metadata for embedding
      const trackInfo = AudioHandler.getTrackInfo() || {};
      const metadata = {
        title: trackInfo.title,
        genre: trackInfo.genre,
        coverUrl: info.coverUrl
      };
      
      Widget.setState({ downloading: true });
      const originalTitle = info.title;
      Widget.updateState({ ...info, title: CONFIG.METADATA_ENABLED ? '⏳ Embedding...' : '⏳ Downloading...' });
      
      try {
        await Downloader.download(info.src, info.filename, metadata);
        Widget.updateState({ ...info, title: CONFIG.METADATA_ENABLED ? '✓ Saved + Art!' : '✓ Saved!' });
        setTimeout(() => Widget.updateState({ ...info, title: originalTitle }), 1500);
      } catch (e) {
        warn('Download failed:', e);
        Widget.flashError();
        Widget.updateState({ ...info, title: '✗ Failed' });
        setTimeout(() => Widget.updateState({ ...info, title: originalTitle }), 1500);
      } finally {
        Widget.setState({ downloading: false });
      }
    });
    
    $('#btn-refresh')?.addEventListener('click', (e) => { e.stopPropagation(); AudioHandler.initObserver()(); });
    $('#btn-auto')?.addEventListener('click', (e) => { e.stopPropagation(); AutoSignup.run(); });
    
    // Initial scan + observer
    AudioHandler.initObserver()();
    setTimeout(() => Widget.updateState(AudioHandler.buildAudioInfo()), CONFIG.STATE_CHECK_INTERVAL);
    
    log(`✅ Widget initialized v3.1.0 | Metadata: ${CONFIG.METADATA_ENABLED ? 'ON' : 'OFF'}`);
  };

  // Start when DOM ready
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
  else init();
})();