PlaylistPlus

Bulk copy/move videos across playlists with checkboxes. Export/import playlists as JSON. The missing YouTube power-user tool.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         PlaylistPlus
// @namespace    https://github.com/dudebot/greasyfork-scripts
// @version      0.1.3
// @description  Bulk copy/move videos across playlists with checkboxes. Export/import playlists as JSON. The missing YouTube power-user tool.
// @author       dudebot
// @match        https://www.youtube.com/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ─────────────────────────────────────────────────────────────────────────
  // Config
  // ─────────────────────────────────────────────────────────────────────────
  const CFG = {
    STORAGE_VERSION: 1,
    BATCH_SIZE: 100,
    MAX_BATCH_RETRIES: 3,
    PACE_MU_MS: 1200,
    PACE_SIGMA_MS: 400,
    PACE_MIN_MS: 500,
    PACE_MAX_MS: 4000,
    BACKOFF_START_MS: 5000,
    BACKOFF_MAX_MS: 60000,
    BACKOFF_MAX_ATTEMPTS: 3,
    WARN_BULK_THRESHOLD: 500,
    DEBUG: false,
  };

  const log = (...a) => { if (CFG.DEBUG) console.log('[YTPM]', ...a); };
  const warn = (...a) => console.warn('[YTPM]', ...a);
  const err = (...a) => console.error('[YTPM]', ...a);

  // ─────────────────────────────────────────────────────────────────────────
  // Storage (GM_setValue with localStorage fallback, account-scoped)
  // ─────────────────────────────────────────────────────────────────────────
  const storage = {
    _backend: typeof GM_setValue === 'function' ? 'gm' : 'ls',
    _idHash: null,

    async _initIdHash() {
      if (this._idHash) return this._idHash;
      const cfg = window.ytcfg;
      const dsid = cfg?.get?.('DELEGATED_SESSION_ID') || '';
      const chid = cfg?.get?.('CHANNEL_ID') || '';
      const sidx = cfg?.get?.('SESSION_INDEX') || '0';
      const raw = dsid || `${sidx}:${chid}`;
      if (!raw || raw === '0:') {
        this._idHash = 'anon';
        return this._idHash;
      }
      const enc = new TextEncoder().encode(raw);
      const buf = await crypto.subtle.digest('SHA-256', enc);
      const hex = [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
      this._idHash = hex.slice(0, 12);
      return this._idHash;
    },

    async _k(suffix) {
      const h = await this._initIdHash();
      return `ytpm:v${CFG.STORAGE_VERSION}:acct:${h}:${suffix}`;
    },

    async get(suffix, fallback = null) {
      const k = await this._k(suffix);
      if (this._backend === 'gm') return GM_getValue(k, fallback);
      const v = localStorage.getItem(k);
      return v == null ? fallback : JSON.parse(v);
    },

    async set(suffix, val) {
      const k = await this._k(suffix);
      if (this._backend === 'gm') return GM_setValue(k, val);
      localStorage.setItem(k, JSON.stringify(val));
    },

    async del(suffix) {
      const k = await this._k(suffix);
      if (this._backend === 'gm') return GM_deleteValue(k);
      localStorage.removeItem(k);
    },
  };

  // ─────────────────────────────────────────────────────────────────────────
  // Pacing — log-normal jittered delays, single-flight writes
  // ─────────────────────────────────────────────────────────────────────────
  const pacing = {
    _lastWrite: 0,
    _queue: Promise.resolve(),

    _jitter() {
      // Box-Muller for log-normal
      const u1 = Math.random() || 1e-9;
      const u2 = Math.random();
      const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
      // log-normal: exp(mean_log + sigma_log * z)
      const ms = Math.exp(Math.log(CFG.PACE_MU_MS) + (CFG.PACE_SIGMA_MS / CFG.PACE_MU_MS) * z);
      return Math.min(CFG.PACE_MAX_MS, Math.max(CFG.PACE_MIN_MS, ms));
    },

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

    async writeGate() {
      const now = Date.now();
      const since = now - this._lastWrite;
      const delay = this._jitter();
      if (since < delay) await this.sleep(delay - since);
      if (document.hidden) {
        // wait for visibility
        await new Promise(r => {
          const h = () => { if (!document.hidden) { document.removeEventListener('visibilitychange', h); r(); } };
          document.addEventListener('visibilitychange', h);
        });
      }
      this._lastWrite = Date.now();
    },

    // Serializes all write operations
    async serialize(fn) {
      const p = this._queue.then(() => fn());
      this._queue = p.catch(() => {});
      return p;
    },
  };

  // ─────────────────────────────────────────────────────────────────────────
  // ytcfg / auth — extract session + compute SAPISIDHASH
  // ─────────────────────────────────────────────────────────────────────────
  const auth = {
    _sapisid: null,

    _readSAPISID() {
      if (this._sapisid) return this._sapisid;
      const jar = {};
      for (const c of document.cookie.split(';').map(s => s.trim())) {
        const i = c.indexOf('=');
        if (i > 0) jar[c.slice(0, i)] = c.slice(i + 1);
      }
      // Explicit priority — SAPISID preferred over secure variants
      const priority = ['SAPISID', '__Secure-3PAPISID', '__Secure-1PAPISID'];
      for (const k of priority) if (jar[k]) { this._sapisid = jar[k]; return jar[k]; }
      return null;
    },

    async sapisidhash() {
      const sid = this._readSAPISID();
      if (!sid) throw new Error('No SAPISID cookie — are you signed in?');
      const ts = Math.floor(Date.now() / 1000);
      const origin = location.origin;
      const raw = `${ts} ${sid} ${origin}`;
      const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(raw));
      const hex = [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
      return `${ts}_${hex}`;
    },

    async authHeader() {
      const h = await this.sapisidhash();
      return `SAPISIDHASH ${h}`;
    },

    ytcfgGet(key) {
      return window.ytcfg?.get?.(key);
    },

    context() {
      const c = this.ytcfgGet('INNERTUBE_CONTEXT');
      if (!c) throw new Error('INNERTUBE_CONTEXT missing — page not fully loaded');
      return c;
    },

    apiKey() {
      const k = this.ytcfgGet('INNERTUBE_API_KEY');
      if (!k) throw new Error('INNERTUBE_API_KEY missing');
      return k;
    },

    // Stable identity string for the currently-active YouTube account. Covers
    // both multi-Google-login (SESSION_INDEX differs) and brand/channel
    // accounts (DELEGATED_SESSION_ID differs under the same Google account).
    identityTag() {
      return `${this.ytcfgGet('SESSION_INDEX') || 0}|${this.ytcfgGet('DELEGATED_SESSION_ID') || ''}`;
    },
  };

  // ─────────────────────────────────────────────────────────────────────────
  // InnerTube HTTP client
  // ─────────────────────────────────────────────────────────────────────────
  const innertube = {
    async call(endpoint, body, { isWrite = false } = {}) {
      const key = auth.apiKey();
      const url = `${location.origin}/youtubei/v1/${endpoint}?key=${encodeURIComponent(key)}&prettyPrint=false`;
      const headers = {
        'Content-Type': 'application/json',
        'Authorization': await auth.authHeader(),
        'X-Origin': location.origin,
        'X-Goog-AuthUser': String(auth.ytcfgGet('SESSION_INDEX') || 0),
        'X-Youtube-Client-Name': String(auth.ytcfgGet('INNERTUBE_CONTEXT_CLIENT_NAME') || 1),
        'X-Youtube-Client-Version': auth.ytcfgGet('INNERTUBE_CONTEXT_CLIENT_VERSION') || '2.0',
      };
      // Brand/channel accounts under a Google account are identified by
      // DELEGATED_SESSION_ID. Without X-Goog-PageId, YouTube auths the
      // request as the parent Google account instead of the brand.
      const pageId = auth.ytcfgGet('DELEGATED_SESSION_ID');
      if (pageId) headers['X-Goog-PageId'] = pageId;
      const ctx = auth.context();
      const payload = { context: ctx, ...body };
      if (isWrite) await pacing.writeGate();
      let attempt = 0;
      let backoff = CFG.BACKOFF_START_MS;
      for (;;) {
        const res = await fetch(url, {
          method: 'POST',
          credentials: 'include',
          headers,
          body: JSON.stringify(payload),
        });
        if (res.status === 429 || res.status === 503) {
          attempt++;
          if (attempt > CFG.BACKOFF_MAX_ATTEMPTS) throw new Error(`Rate-limited after ${attempt} retries (${res.status})`);
          const retryAfter = parseInt(res.headers.get('Retry-After') || '0', 10) * 1000;
          await pacing.sleep(Math.max(retryAfter, backoff));
          backoff = Math.min(CFG.BACKOFF_MAX_MS, backoff * 2);
          continue;
        }
        if (res.status === 401 || res.status === 403) {
          throw new Error(`Auth rejected (${res.status}) — reload the page`);
        }
        if (!res.ok) throw new Error(`InnerTube ${endpoint} failed: ${res.status}`);
        return res.json();
      }
    },

    browse(body) { return this.call('browse', body); },
    playlistEdit(body) { return this.call('browse/edit_playlist', body, { isWrite: true }); },
    playlistCreate(body) { return this.call('playlist/create', body, { isWrite: true }); },
  };

  // ─────────────────────────────────────────────────────────────────────────
  // Reader — paginate playlist items, track videoId + setVideoId
  // ─────────────────────────────────────────────────────────────────────────
  const reader = {
    _extractItems(renderers) {
      const items = [];
      for (const r of renderers || []) {
        const v = r.playlistVideoRenderer;
        if (v) {
          items.push({
            videoId: v.videoId,
            setVideoId: v.setVideoId,
            title: v.title?.simpleText || v.title?.runs?.[0]?.text || '',
            channelId: v.shortBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || '',
            channelName: v.shortBylineText?.runs?.[0]?.text || '',
            isPlayable: v.isPlayable !== false,
            deleted: v.isPlayable === false,
          });
          continue;
        }
        const cont = r.continuationItemRenderer;
        if (cont) {
          items.push({ __continuation: cont.continuationEndpoint?.continuationCommand?.token });
        }
      }
      return items;
    },

    async loadPlaylist(playlistId, onProgress) {
      const items = [];
      let resp = await innertube.browse({ browseId: `VL${playlistId}` });
      const header = this._extractHeader(resp);
      let renderers = this._findPlaylistRenderers(resp);
      while (renderers && renderers.length) {
        const extracted = this._extractItems(renderers);
        const cont = extracted.find(x => x.__continuation);
        for (const it of extracted) if (!it.__continuation) items.push(it);
        if (onProgress) onProgress({ loaded: items.length, total: header.itemCount });
        if (!cont) break;
        resp = await innertube.browse({ continuation: cont.__continuation });
        renderers = this._findContinuationRenderers(resp);
      }
      return { header, items };
    },

    _extractHeader(resp) {
      const h = resp.header?.playlistHeaderRenderer
             || resp.metadata?.playlistMetadataRenderer
             || resp.sidebar?.playlistSidebarRenderer?.items?.[0]?.playlistSidebarPrimaryInfoRenderer;
      const countText = h?.numVideosText?.runs?.[0]?.text
                     || h?.stats?.[0]?.runs?.[0]?.text
                     || '';
      const title = h?.title?.simpleText || h?.title?.runs?.[0]?.text || '';
      return {
        title,
        itemCount: parseInt(countText.replace(/[^\d]/g, ''), 10) || null,
      };
    },

    _findPlaylistRenderers(resp) {
      const tabs = resp.contents?.twoColumnBrowseResultsRenderer?.tabs;
      if (!tabs) return null;
      for (const t of tabs) {
        const sections = t.tabRenderer?.content?.sectionListRenderer?.contents;
        if (!sections) continue;
        for (const s of sections) {
          const items = s.itemSectionRenderer?.contents?.[0]?.playlistVideoListRenderer?.contents;
          if (items) return items;
        }
      }
      return null;
    },

    _findContinuationRenderers(resp) {
      const buckets = [
        resp.onResponseReceivedActions,
        resp.onResponseReceivedEndpoints,
        resp.onResponseReceivedCommands,
      ].filter(Boolean).flat();
      for (const a of buckets) {
        const cont = a.appendContinuationItemsAction?.continuationItems
                  || a.reloadContinuationItemsCommand?.continuationItems;
        if (cont) return cont;
      }
      return null;
    },

    // Fetch the list of user's owned playlists (for destination picker)
    async loadOwnedPlaylists() {
      // Use the /feed/library endpoint via browseId FEplaylist_aggregation
      const resp = await innertube.browse({ browseId: 'FEplaylist_aggregation' });
      const out = [];
      const tabs = resp.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
      const walk = (node) => {
        if (!node || typeof node !== 'object') return;
        if (Array.isArray(node)) { node.forEach(walk); return; }
        if (node.lockupViewModel?.contentId && node.lockupViewModel.contentType === 'LOCKUP_CONTENT_TYPE_PLAYLIST') {
          const meta = node.lockupViewModel.metadata?.lockupMetadataViewModel;
          out.push({
            id: node.lockupViewModel.contentId,
            title: meta?.title?.content || '',
          });
          return;
        }
        if (node.playlistLockupViewModel) {
          out.push({
            id: node.playlistLockupViewModel.contentId || node.playlistLockupViewModel.playlistId,
            title: node.playlistLockupViewModel.metadata?.lockupMetadataViewModel?.title?.content || '',
          });
          return;
        }
        for (const k of Object.keys(node)) walk(node[k]);
      };
      walk(tabs);
      // dedupe
      const seen = new Set();
      return out.filter(p => p.id && !seen.has(p.id) && seen.add(p.id));
    },
  };

  // ─────────────────────────────────────────────────────────────────────────
  // Mutator — build actions[] and dispatch in batches with verification
  // ─────────────────────────────────────────────────────────────────────────
  const mutator = {
    async addVideos(playlistId, videoIds, onProgress) {
      return this._batchedEdit(playlistId, videoIds.map(v => ({
        action: 'ACTION_ADD_VIDEO',
        addedVideoId: v,
      })), { mode: 'add', videoIds }, onProgress);
    },

    async removeVideos(playlistId, setVideoIds, onProgress) {
      return this._batchedEdit(playlistId, setVideoIds.map(s => ({
        action: 'ACTION_REMOVE_VIDEO_BY_SET_VIDEO_ID',
        setVideoId: s,
      })), { mode: 'remove', setVideoIds }, onProgress);
    },

    async _batchedEdit(playlistId, actions, meta, onProgress) {
      const result = { applied: 0, failed: [], retried: 0 };
      const batches = [];
      for (let i = 0; i < actions.length; i += CFG.BATCH_SIZE) {
        batches.push(actions.slice(i, i + CFG.BATCH_SIZE));
      }
      for (let bi = 0; bi < batches.length; bi++) {
        const batch = batches[bi];
        // capture pre-batch count for add verification
        if (meta.mode === 'add') {
          const { items } = await reader.loadPlaylist(playlistId);
          meta._preCount = new Map();
          for (const i of items) meta._preCount.set(i.videoId, (meta._preCount.get(i.videoId) || 0) + 1);
        }
        await pacing.serialize(() => this._runAndVerifyBatch(playlistId, batch, meta, result));
        if (onProgress) onProgress({
          batch: bi + 1,
          totalBatches: batches.length,
          applied: result.applied,
          failed: result.failed.length,
        });
      }
      return result;
    },

    async _runAndVerifyBatch(playlistId, batch, meta, result, depth = 0) {
      try {
        await innertube.playlistEdit({
          playlistId,
          actions: batch,
        });
      } catch (e) {
        warn('playlistEdit threw:', e.message);
        // treat entire batch as failed
        if (depth >= CFG.MAX_BATCH_RETRIES) {
          result.failed.push(...batch);
          return;
        }
        await pacing.sleep(CFG.BACKOFF_START_MS * (depth + 1));
      }

      // Verify: re-read the target playlist and check whether our actions landed.
      // For adds, compare multiset counts (not set membership) so pre-existing
      // entries don't mask dropped mutations.
      const { items } = await reader.loadPlaylist(playlistId);
      const countByVideo = new Map();
      for (const i of items) countByVideo.set(i.videoId, (countByVideo.get(i.videoId) || 0) + 1);
      const presentSetIds = new Set(items.map(i => i.setVideoId));

      const expectedAdds = new Map(); // videoId -> expected count after batch
      if (meta.mode === 'add') {
        // Snapshot of expected counts: previous count (approx via current - 1 if dropped) is unknowable
        // without pre-snapshot. Use the _preSnapshot captured before batch.
        const pre = meta._preCount || new Map();
        for (const act of batch) {
          const v = act.addedVideoId;
          expectedAdds.set(v, (expectedAdds.get(v) || pre.get(v) || 0) + 1);
        }
      }

      const missing = [];
      if (meta.mode === 'add') {
        // for each unique videoId expected, compare current count to expected
        for (const [vid, exp] of expectedAdds) {
          const cur = countByVideo.get(vid) || 0;
          const short = exp - cur;
          if (short > 0) {
            const acts = batch.filter(a => a.addedVideoId === vid).slice(0, short);
            missing.push(...acts);
          }
        }
      } else {
        for (const act of batch) {
          if (presentSetIds.has(act.setVideoId)) missing.push(act);
        }
      }

      result.applied += batch.length - missing.length;

      if (missing.length && depth < CFG.MAX_BATCH_RETRIES) {
        result.retried += missing.length;
        log(`Verifier: ${missing.length}/${batch.length} silently dropped, retrying (depth ${depth + 1})`);
        // Halve batch size on retry
        const half = Math.max(1, Math.floor(missing.length / 2));
        for (let i = 0; i < missing.length; i += half) {
          await this._runAndVerifyBatch(playlistId, missing.slice(i, i + half), meta, result, depth + 1);
        }
      } else if (missing.length) {
        result.failed.push(...missing);
      }
    },
  };

  // ─────────────────────────────────────────────────────────────────────────
  // Export / Import
  // ─────────────────────────────────────────────────────────────────────────
  const portability = {
    async exportPlaylist(playlistId, { includeUnlisted = false } = {}) {
      const { header, items } = await reader.loadPlaylist(playlistId);
      const cleanItems = items.map(i => ({
        videoId: i.videoId,
        setVideoId: i.setVideoId || null,
        title: i.title,
        channelId: i.channelId || null,
        channelName: i.channelName || null,
        isPlayable: i.isPlayable,
        deleted: i.deleted,
      }));
      return {
        schema: 'ytpm.bundle/1',
        exportedAt: new Date().toISOString(),
        origin: location.origin,
        playlists: [{
          id: playlistId,
          title: header.title,
          itemCount: cleanItems.length,
          items: cleanItems,
        }],
      };
    },

    async importIntoPlaylist(targetPlaylistId, bundle, { dedupe = true } = {}) {
      const pls = bundle.playlists || [];
      const existingIds = new Set();
      if (dedupe) {
        const { items } = await reader.loadPlaylist(targetPlaylistId);
        items.forEach(i => existingIds.add(i.videoId));
      }
      const videoIds = [];
      for (const p of pls) {
        for (const it of p.items || []) {
          if (it.deleted) continue;
          if (dedupe && existingIds.has(it.videoId)) continue;
          videoIds.push(it.videoId);
        }
      }
      return mutator.addVideos(targetPlaylistId, videoIds);
    },

    downloadJSON(data, filename) {
      const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      a.click();
      URL.revokeObjectURL(url);
    },

    async readFile(file) {
      const text = await file.text();
      return JSON.parse(text);
    },
  };

  // ─────────────────────────────────────────────────────────────────────────
  // DOM adapter — detect playlist pages, inject checkboxes
  // ─────────────────────────────────────────────────────────────────────────
  const dom = {
    _observer: null,
    _selected: new Map(), // videoId -> { setVideoId, title }
    _listeners: new Set(),

    currentPlaylistId() {
      const u = new URL(location.href);
      const list = u.searchParams.get('list');
      if (list) return list;
      if (location.pathname === '/feed/liked') return 'LL';
      return null;
    },

    isPlaylistPage() {
      return location.pathname === '/playlist'
          || location.pathname === '/feed/liked'
          || (location.pathname === '/watch' && new URL(location.href).searchParams.get('list'));
    },

    onSelectionChange(fn) { this._listeners.add(fn); return () => this._listeners.delete(fn); },
    _emit() { for (const fn of this._listeners) fn(this._selected); },

    clearSelection() { this._selected.clear(); this._refreshCheckboxes(); this._emit(); },
    getSelection() { return new Map(this._selected); },

    _refreshCheckboxes() {
      const rows = document.querySelectorAll('ytd-playlist-video-renderer');
      rows.forEach(r => {
        const vid = this._videoIdOf(r);
        const cb = r.querySelector('.ytpm-cb');
        if (cb) cb.checked = this._selected.has(vid);
      });
    },

    _videoIdOf(row) {
      const link = row.querySelector('a#video-title, a#thumbnail');
      if (!link) return null;
      const href = link.getAttribute('href') || '';
      const m = href.match(/[?&]v=([^&]+)/);
      return m ? m[1] : null;
    },

    _setVideoIdOf(row) {
      const d = row.data || row.polymerController?.data || row.__data;
      return d?.setVideoId
          || d?.playlistVideoRenderer?.setVideoId
          || null;
    },

    _titleOf(row) {
      return row.querySelector('#video-title')?.textContent?.trim() || '';
    },

    injectCheckboxes() {
      const rows = document.querySelectorAll('ytd-playlist-video-renderer');
      rows.forEach(row => {
        if (row.querySelector('.ytpm-cb')) return;
        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.className = 'ytpm-cb';
        cb.style.cssText = 'margin-right: 8px; width: 18px; height: 18px; cursor: pointer; accent-color: #f00;';
        cb.addEventListener('click', (e) => {
          e.stopPropagation();
          const vid = this._videoIdOf(row);
          if (!vid) return;
          if (cb.checked) {
            this._selected.set(vid, {
              setVideoId: this._setVideoIdOf(row),
              title: this._titleOf(row),
            });
          } else {
            this._selected.delete(vid);
          }
          this._emit();
        });
        const anchor = row.querySelector('#index-container') || row.querySelector('#index') || row.firstElementChild;
        if (anchor) anchor.insertBefore(cb, anchor.firstChild);
      });
      this._refreshCheckboxes();
    },

    selectAll() {
      const rows = document.querySelectorAll('ytd-playlist-video-renderer');
      rows.forEach(row => {
        const vid = this._videoIdOf(row);
        if (vid) this._selected.set(vid, {
          setVideoId: this._setVideoIdOf(row),
          title: this._titleOf(row),
        });
      });
      this._refreshCheckboxes();
      this._emit();
    },

    start() {
      if (this._observer) return;
      this._observer = new MutationObserver(() => {
        if (this.isPlaylistPage()) this.injectCheckboxes();
      });
      this._observer.observe(document.body, { childList: true, subtree: true });
      // also hook SPA navigation
      window.addEventListener('yt-navigate-finish', () => {
        this._selected.clear();
        this._emit();
        ui._updateVisibility();
        ui._refreshAcct();
        setTimeout(() => this.injectCheckboxes(), 500);
      });
    },
  };

  // ─────────────────────────────────────────────────────────────────────────
  // UI — Shadow DOM floating panel
  // ─────────────────────────────────────────────────────────────────────────
  const ui = {
    _root: null,
    _shadow: null,
    _el: {},
    _ownedPlaylists: [],
    _ownedPlaylistsTag: null,
    _log: [],

    _logMsg(msg, kind = 'info') {
      this._log.push({ t: Date.now(), msg, kind });
      if (this._log.length > 100) this._log.shift();
      this._renderLog();
    },

    _renderLog() {
      if (!this._el.log) return;
      setHTML(this._el.log, this._log.slice(-10).reverse().map(e =>
        `<div class="log-${e.kind}">${escapeHtml(e.msg)}</div>`
      ).join(''));
    },

    mount() {
      if (this._root) return;
      this._root = document.createElement('div');
      this._root.id = 'ytpm-root';
      this._root.style.cssText = 'position:fixed;bottom:16px;right:16px;z-index:2147483647;';
      this._shadow = this._root.attachShadow({ mode: 'open' });
      document.body.appendChild(this._root);

      setHTML(this._shadow, `
        <style>
          :host { all: initial; }
          .panel {
            font: 13px/1.4 'Roboto','Arial',sans-serif;
            background: #212121; color: #eee;
            border: 1px solid #3a3a3a; border-radius: 12px;
            width: 320px; max-height: 70vh;
            display: flex; flex-direction: column;
            box-shadow: 0 6px 20px rgba(0,0,0,.4);
            overflow: hidden;
          }
          .panel.collapsed { width: 52px; height: 52px; border-radius: 26px; align-items: center; justify-content: center; cursor: pointer; }
          .panel.collapsed > *:not(.badge) { display: none; }
          .panel.collapsed .badge { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 20px; font-weight: bold; color: #f00; }
          .header { display:flex; align-items:center; padding: 8px 12px; border-bottom: 1px solid #3a3a3a; gap: 8px; }
          .title { flex: 1; font-weight: 600; }
          .acct { font-weight: 400; font-size: 11px; color: #888; margin-left: 4px; }
          .count { font-variant-numeric: tabular-nums; color: #f00; font-weight: 600; }
          button.icon { background:none;border:none;color:#ccc;cursor:pointer;padding:4px 6px; font-size:14px; }
          button.icon:hover { color:#fff; }
          .actions { display:grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 8px 12px; border-bottom: 1px solid #2a2a2a; }
          button.btn { background:#303030;border:none;color:#eee;padding:8px;border-radius:4px;cursor:pointer;font-size:12px; }
          button.btn:hover { background:#404040; }
          button.btn.primary { background:#c00; color:#fff; }
          button.btn.primary:hover { background:#e00; }
          button.btn:disabled { opacity:.5; cursor:not-allowed; }
          .destpicker { padding: 4px 12px; display:flex; flex-direction:column; }
          .destpicker .dest-list { max-height: 180px; overflow:auto; margin: 6px 0; }
          .destpicker .dest-actions { text-align:right; padding-top:6px; border-top:1px solid #2a2a2a; }
          .dest-row { display:flex; align-items:center; padding:4px; cursor:pointer; border-radius:3px; }
          .dest-row:hover { background:#2a2a2a; }
          .dest-row input { margin-right:6px; }
          .dest-row .dest-title { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
          .log { flex:1; max-height:120px; overflow:auto; padding: 6px 12px; font-size:11px; color:#aaa; border-top:1px solid #2a2a2a; }
          .log-info { color:#aaa; }
          .log-ok { color:#7e7; }
          .log-warn { color:#fd7; }
          .log-err { color:#f77; }
          .progress { height:4px; background:#333; }
          .progress > .bar { height:100%; background:#f00; width:0%; transition:width .2s; }
          input[type="file"] { display:none; }
          .hint { font-size: 11px; color: #888; padding: 4px 12px; }
        </style>
        <div class="panel collapsed" id="panel">
          <div class="badge" id="badge">P</div>
          <div class="header">
            <div class="title">Playlist Manager <span class="acct" id="acct" title="Active account"></span></div>
            <span class="count" id="count">0</span>
            <button class="icon" id="collapse" title="Collapse">—</button>
          </div>
          <div class="progress"><div class="bar" id="bar"></div></div>
          <div class="actions">
            <button class="btn" id="selall">Select all</button>
            <button class="btn" id="clear">Clear</button>
            <button class="btn primary" id="copy">Copy to…</button>
            <button class="btn primary" id="move">Move to…</button>
            <button class="btn" id="export">Export JSON</button>
            <label class="btn" style="text-align:center;cursor:pointer;" for="importfile">Import JSON</label>
            <input type="file" id="importfile" accept=".json,application/json">
          </div>
          <div class="destpicker" id="destpicker" style="display:none"></div>
          <div class="log" id="log"></div>
          <div class="hint">alpha v0.1 · undo: right-click panel header</div>
        </div>
      `);

      const $ = (id) => this._shadow.getElementById(id);
      this._el = {
        panel: $('panel'), badge: $('badge'), count: $('count'), bar: $('bar'),
        collapse: $('collapse'), selall: $('selall'), clear: $('clear'),
        copy: $('copy'), move: $('move'), export: $('export'),
        importfile: $('importfile'), destpicker: $('destpicker'), log: $('log'),
        acct: $('acct'),
      };
      this._refreshAcct();
      this._updateVisibility();

      this._el.badge.addEventListener('click', () => this._toggleExpand());
      this._el.collapse.addEventListener('click', () => this._toggleExpand());
      this._el.selall.addEventListener('click', () => dom.selectAll());
      this._el.clear.addEventListener('click', () => dom.clearSelection());
      this._el.copy.addEventListener('click', () => this._showDestPicker('copy'));
      this._el.move.addEventListener('click', () => this._showDestPicker('move'));
      this._el.export.addEventListener('click', () => this._doExport());
      this._el.importfile.addEventListener('change', (e) => this._doImport(e.target.files[0]));

      dom.onSelectionChange(sel => this._onSelectionChange(sel));
      this._renderLog();
    },

    _toggleExpand() {
      this._el.panel.classList.toggle('collapsed');
      if (!this._el.panel.classList.contains('collapsed')) {
        this._loadOwnedPlaylists();
      }
    },

    _refreshAcct() {
      if (!this._el.acct) return;
      const idx = auth.ytcfgGet('SESSION_INDEX') || 0;
      const brand = auth.ytcfgGet('DELEGATED_SESSION_ID');
      const label = brand
        ? `brand ${String(brand).slice(0, 6)}…`
        : `authuser ${idx}`;
      this._el.acct.textContent = `· ${label}`;
    },

    _updateVisibility() {
      if (!this._root) return;
      const show = dom.isPlaylistPage();
      this._root.style.display = show ? '' : 'none';
    },

    async _loadOwnedPlaylists() {
      const tag = auth.identityTag();
      if (this._ownedPlaylists.length && this._ownedPlaylistsTag === tag) return;
      try {
        this._ownedPlaylists = await reader.loadOwnedPlaylists();
        this._ownedPlaylistsTag = tag;
        this._logMsg(`Loaded ${this._ownedPlaylists.length} of your playlists`, 'ok');
      } catch (e) {
        this._logMsg(`Failed to load playlists: ${e.message}`, 'err');
      }
    },

    _onSelectionChange(sel) {
      this._el.count.textContent = sel.size;
      this._el.badge.textContent = sel.size || 'P';
      this._el.badge.style.color = sel.size ? '#fff' : '#f00';
    },

    _showDestPicker(mode) {
      const sel = dom.getSelection();
      if (!sel.size) { this._logMsg('No videos selected', 'warn'); return; }
      const srcId = dom.currentPlaylistId();
      const candidates = this._ownedPlaylists.filter(p => p.id !== srcId);
      if (!candidates.length) {
        this._logMsg('No other playlists found — reload and try again', 'warn');
        return;
      }
      setHTML(this._el.destpicker, `
        <div style="font-weight:600;">${mode === 'move' ? 'Move' : 'Copy'} ${sel.size} videos to:</div>
        <div class="dest-list">
          ${candidates.map(p => `
            <label class="dest-row">
              <input type="checkbox" value="${escapeHtml(p.id)}">
              <span class="dest-title">${escapeHtml(p.title)}</span>
            </label>
          `).join('')}
        </div>
        <div class="dest-actions">
          <button class="btn" id="cancelpick">Cancel</button>
          <button class="btn primary" id="confirmpick">Go</button>
        </div>
      `);
      this._el.destpicker.style.display = 'block';
      this._shadow.getElementById('cancelpick').onclick = () => {
        this._el.destpicker.style.display = 'none';
      };
      this._shadow.getElementById('confirmpick').onclick = async () => {
        const picks = [...this._shadow.querySelectorAll('.dest-row input:checked')].map(i => i.value);
        if (!picks.length) { this._logMsg('Pick at least one destination', 'warn'); return; }
        this._el.destpicker.style.display = 'none';
        await this._runBulkOp(mode, picks, sel);
      };
    },

    async _runBulkOp(mode, destIds, sel) {
      const srcId = dom.currentPlaylistId();
      const videoIds = [...sel.keys()];
      let setVideoIds = [...sel.values()].map(v => v.setVideoId).filter(Boolean);

      // For move, resolve setVideoIds reliably via InnerTube if DOM scraping came up short
      if (mode === 'move' && setVideoIds.length !== sel.size) {
        this._logMsg(`Resolving setVideoIds from source playlist…`);
        try {
          const { items } = await reader.loadPlaylist(srcId);
          const byVid = new Map();
          for (const it of items) if (it.setVideoId) byVid.set(it.videoId, it.setVideoId);
          setVideoIds = videoIds.map(v => byVid.get(v)).filter(Boolean);
          if (setVideoIds.length !== sel.size) {
            this._logMsg(`Warning: only resolved ${setVideoIds.length}/${sel.size} setVideoIds; proceeding with what we have`, 'warn');
          }
        } catch (e) {
          this._logMsg(`Failed to resolve setVideoIds: ${e.message}. Aborting move.`, 'err');
          return;
        }
      }
      if (sel.size > CFG.WARN_BULK_THRESHOLD) {
        if (!confirm(`You're about to ${mode} ${sel.size} videos across ${destIds.length} playlists. Continue?`)) return;
      }

      this._logMsg(`${mode === 'move' ? 'Moving' : 'Copying'} ${videoIds.length} videos → ${destIds.length} playlists…`);
      try {
        for (const destId of destIds) {
          this._logMsg(`  → ${destId}`);
          const r = await mutator.addVideos(destId, videoIds, p => {
            this._setProgress(p.applied / videoIds.length);
            this._el.count.textContent = `${p.applied}/${videoIds.length}`;
          });
          this._logMsg(`  + added ${r.applied}, retried ${r.retried}, failed ${r.failed.length}`, r.failed.length ? 'warn' : 'ok');
        }
        if (mode === 'move') {
          this._logMsg(`Removing ${setVideoIds.length} from source…`);
          const r = await mutator.removeVideos(srcId, setVideoIds, p => {
            this._setProgress(p.applied / setVideoIds.length);
          });
          this._logMsg(`- removed ${r.applied}, failed ${r.failed.length}`, r.failed.length ? 'warn' : 'ok');
        }
        dom.clearSelection();
        this._setProgress(0);
        this._logMsg(`Done.`, 'ok');
      } catch (e) {
        err(e);
        this._logMsg(`Error: ${e.message}`, 'err');
      }
    },

    _setProgress(frac) {
      this._el.bar.style.width = `${Math.max(0, Math.min(1, frac)) * 100}%`;
    },

    async _doExport() {
      const id = dom.currentPlaylistId();
      if (!id) { this._logMsg('Not on a playlist page', 'warn'); return; }
      this._logMsg('Exporting…');
      try {
        const data = await portability.exportPlaylist(id);
        const title = data.playlists[0].title.replace(/[^\w\-]/g, '_').slice(0, 40) || 'playlist';
        portability.downloadJSON(data, `ytpm-${title}-${id}.json`);
        this._logMsg(`Exported ${data.playlists[0].items.length} items`, 'ok');
      } catch (e) {
        this._logMsg(`Export failed: ${e.message}`, 'err');
      }
    },

    async _doImport(file) {
      if (!file) return;
      const id = dom.currentPlaylistId();
      if (!id) { this._logMsg('Open a target playlist first', 'warn'); return; }
      try {
        const bundle = await portability.readFile(file);
        if (bundle.schema !== 'ytpm.bundle/1') {
          this._logMsg(`Unknown schema: ${bundle.schema}`, 'warn');
        }

        // Preview: read the target and count how many items will actually
        // land (after dedupe + deleted-item filtering). Confirm before write.
        this._logMsg(`Previewing target playlist…`);
        const { header, items } = await reader.loadPlaylist(id);
        const existing = new Set(items.map(i => i.videoId));
        let candidate = 0, skipDup = 0, skipDel = 0;
        for (const p of bundle.playlists || []) {
          for (const it of p.items || []) {
            if (it.deleted) { skipDel++; continue; }
            if (existing.has(it.videoId)) { skipDup++; continue; }
            candidate++;
          }
        }
        const title = header.title || id;
        if (candidate === 0) {
          this._logMsg(`Nothing to import (${skipDup} dupes, ${skipDel} removed from YT)`, 'warn');
          return;
        }
        const msg =
          `Add ${candidate} new video${candidate === 1 ? '' : 's'} to "${title}"?\n` +
          (skipDup || skipDel
            ? `(${skipDup} already in playlist, ${skipDel} no longer on YouTube — will be skipped)`
            : '');
        if (!confirm(msg)) { this._logMsg('Import cancelled', 'warn'); return; }

        this._logMsg(`Importing ${candidate} into "${title}"…`);
        const r = await portability.importIntoPlaylist(id, bundle, { dedupe: true });
        this._logMsg(`Imported: applied=${r.applied} failed=${r.failed.length}`, r.failed.length ? 'warn' : 'ok');
      } catch (e) {
        this._logMsg(`Import failed: ${e.message}`, 'err');
      }
    },
  };

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

  // YouTube serves `require-trusted-types-for 'script'`; raw innerHTML
  // assignment throws. Route through a named policy so shadow/panel HTML
  // renders under the CSP.
  const ttPolicy = (() => {
    try { return window.trustedTypes?.createPolicy?.('ytpm', { createHTML: s => s }) || null; }
    catch { return null; }
  })();
  function setHTML(el, html) {
    el.innerHTML = ttPolicy ? ttPolicy.createHTML(html) : html;
  }

  // ─────────────────────────────────────────────────────────────────────────
  // Boot
  // ─────────────────────────────────────────────────────────────────────────
  let _bootTries = 0;
  function boot() {
    if (!window.ytcfg) {
      if (++_bootTries % 25 === 0) console.warn('[YTPM] still waiting for window.ytcfg (tries=' + _bootTries + '). If this never goes away, the userscript is running in an isolated sandbox — set @grant none.');
      setTimeout(boot, 200);
      return;
    }
    try {
      ui.mount();
      dom.start();
      console.log('[YTPM] mounted (v0.1.3)');
    } catch (e) {
      console.error('[YTPM] mount failed:', e);
    }
  }
  if (document.readyState === 'complete' || document.readyState === 'interactive') boot();
  else window.addEventListener('DOMContentLoaded', boot);
})();