Multiselect for Trakt

Bulk-select, copy, move, and delete items on Trakt list pages

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Multiselect for Trakt
// @namespace    https://trakt.tv/
// @version      1.1.0
// @license      MIT
// @description  Bulk-select, copy, move, and delete items on Trakt list pages
// @author       trakt-multiselect
// @match        https://trakt.tv/users/*/lists/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      api.trakt.tv
// @run-at       document-idle
// ==/UserScript==

/*
 * FIRST-TIME SETUP
 * ─────────────────
 * 1. Create a Trakt API app at https://trakt.tv/oauth/applications/new
 *    Set redirect URI to "urn:ietf:wg:oauth:2.0:oob" to enable device-code flow.
 * 2. On a Trakt list page, open the Tampermonkey menu and click
 *    "⚙️ Configure Multiselect for Trakt" to enter your Client ID & Secret.
 * 3. Click "Authorize…" and follow the device-code prompt.
 * 4. Done — the red FAB button will appear at the bottom-center of every list page.
 */

(function () {
  'use strict';

  // ─── Constants ────────────────────────────────────────────────────────────

  const API_BASE = 'https://api.trakt.tv';
  const API_VERSION = '2';
  const DEBOUNCE_MS = 280;
  const CHECKMARK = `<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.8"
    stroke-linecap="round" stroke-linejoin="round">
    <polyline points="20 6 9 17 4 12"/></svg>`;

  // ─── SVG icons for modal buttons ──────────────────────────────────────────

  const ICON = {
    selectPage: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
      stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
      <rect x="3" y="3" width="18" height="18" rx="2"/>
      <polyline points="8 12 11 15 16 9"/>
    </svg>`,
    unselectPage: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
      stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
      <rect x="3" y="3" width="18" height="18" rx="2"/>
      <line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/>
    </svg>`,
    copy: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
      stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
      <rect x="9" y="9" width="13" height="13" rx="2"/>
      <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
    </svg>`,
    move: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
      stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
      <path d="M5 12h14"/><polyline points="12 5 19 12 12 19"/>
      <rect x="2" y="8" width="5" height="8" rx="1"/>
    </svg>`,
    delete: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
      stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
      <polyline points="3 6 5 6 21 6"/>
      <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
      <path d="M10 11v6"/><path d="M14 11v6"/>
      <path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
    </svg>`,
    fab: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
      stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
      <rect x="3" y="3" width="7" height="7" rx="1.2"/>
      <rect x="14" y="3" width="7" height="7" rx="1.2"/>
      <rect x="3" y="14" width="7" height="7" rx="1.2"/>
      <path d="M14 17.5h7M17.5 14v7"/>
    </svg>`,
  };

  // ─── State ────────────────────────────────────────────────────────────────

  const state = {
    isSelectionMode: false,
    selectedIds: new Set(),
    visibleItems: new Map(),
    currentPageKey: null,
    isOwner: false,
    observer: null,
    debounceTimer: null,
  };

  // ─── Page context ─────────────────────────────────────────────────────────

  function getCtx() {
    const m = location.pathname.match(/^\/users\/([^/]+)\/lists\/([^/?#]+)/);
    return m ? { username: m[1], listSlug: m[2] } : null;
  }

  function getPageKey() {
    return new URLSearchParams(location.search).get('page') || '1';
  }

  function detectOwner() {
    return !!document.querySelector(
      '.btn-list-delete-items, .btn-list-move-items, .btn-list-copy-items',
    );
  }

  // ─── DOM utilities ────────────────────────────────────────────────────────

  function waitForEl(sel, ms = 12000) {
    return new Promise((res, rej) => {
      const found = document.querySelector(sel);
      if (found) return res(found);
      const obs = new MutationObserver(() => {
        const el = document.querySelector(sel);
        if (el) {
          obs.disconnect();
          res(el);
        }
      });
      obs.observe(document.body, { childList: true, subtree: true });
      setTimeout(() => {
        obs.disconnect();
        rej(new Error('Timeout: ' + sel));
      }, ms);
    });
  }

  function normalizeItem(el) {
    const ds = el.dataset;
    const type = ds.type || '';
    const ID_KEY = {
      movie: 'movieId',
      show: 'showId',
      season: 'seasonId',
      episode: 'episodeId',
      person: 'personId',
    };
    return {
      listItemId: ds.listItemId,
      type,
      traktId: ds[ID_KEY[type]] ?? null,
      url: ds.url || '',
      rank: ds.rank || '',
      title:
        el.querySelector('.titles h3')?.textContent?.trim() ||
        el.querySelector('[data-title]')?.dataset?.title ||
        '',
      element: el,
    };
  }

  // ─── CSS ──────────────────────────────────────────────────────────────────

  function injectCSS() {
    if (document.getElementById('trakt-ms-styles')) return;
    const s = document.createElement('style');
    s.id = 'trakt-ms-styles';
    s.textContent = `
/* ── FAB ── */
.trakt-ms-fab {
  position:fixed; bottom:28px; left:50%; transform:translateX(-50%);
  z-index:9000; width:48px; height:48px; border-radius:50%;
  background:#ed1c24; color:#fff; border:none;
  box-shadow:0 4px 18px rgba(0,0,0,.5); cursor:pointer;
  display:flex; align-items:center; justify-content:center;
  transition:transform .15s, box-shadow .15s, background .15s; outline:none;
}
.trakt-ms-fab:hover { transform:translateX(-50%) scale(1.1); box-shadow:0 6px 24px rgba(0,0,0,.6); }
.trakt-ms-fab.active { background:#3a3a5c; }

/* ── Modal — single horizontal bar ── */
.trakt-ms-modal {
  position:fixed; bottom:88px; left:50%; transform:translateX(-50%);
  z-index:9001;
  background:#16162a; border:1px solid #2d2d50;
  border-radius:99px; padding:8px 14px;
  display:none; align-items:center; gap:6px;
  box-shadow:0 8px 32px rgba(0,0,0,.65);
  white-space:nowrap;
  font-family:inherit;
}
.trakt-ms-modal.open { display:flex; }

/* ── Icon buttons ── */
.trakt-ms-btn {
  width:34px; height:34px; border-radius:8px; border:none;
  cursor:pointer; display:flex; align-items:center; justify-content:center;
  transition:filter .12s, opacity .12s, background .12s;
  flex-shrink:0; color:#fff;
}
.trakt-ms-btn:disabled { opacity:.28; cursor:not-allowed; }
.trakt-ms-btn:not(:disabled):hover { filter:brightness(1.22); }
.trakt-ms-btn-page   { background:#252548; color:#ccc; }
.trakt-ms-btn-action { background:#2a2040; color:#a78bfa; }
.trakt-ms-btn-delete { background:#2a1010; color:#f87171; }

/* ── Divider ── */
.trakt-ms-divider {
  width:1px; height:20px; background:#2d2d50; flex-shrink:0; margin:0 2px;
}

/* ── Count ── */
.trakt-ms-modal-count {
  font-size:18px; font-weight:700; color:#ed1c24;
  min-width:24px; text-align:center; line-height:1;
  padding:0 4px;
}

/* ── List picker — anchored above modal ── */
.trakt-ms-list-picker {
  position:fixed; bottom:136px; left:50%; transform:translateX(-50%);
  z-index:9003; background:#1c1c36; border:1px solid #38385a;
  border-radius:10px; max-height:200px; overflow-y:auto;
  min-width:220px; max-width:88vw; display:none;
  box-shadow:0 8px 28px rgba(0,0,0,.55);
}
.trakt-ms-list-picker.open { display:block; }
.trakt-ms-picker-item {
  padding:10px 15px; cursor:pointer; font-size:13px; color:#ccc;
  border-bottom:1px solid #282846; transition:background .1s; font-family:inherit;
}
.trakt-ms-picker-item:last-child { border-bottom:none; }
.trakt-ms-picker-item:hover { background:#26265a; }

/* ── Selection visual state ── */
.trakt-ms-selection-mode #sortable-grid .grid-item { cursor:pointer; }
.trakt-ms-selection-mode #sortable-grid .grid-item * { pointer-events:none !important; }
.trakt-ms-selected {
  outline:3px solid #ed1c24 !important; outline-offset:-3px; position:relative;
}
.trakt-ms-selected::after {
  content:''; position:absolute; inset:0;
  background:rgba(237,28,36,.16); pointer-events:none; z-index:8;
}
.trakt-ms-badge {
  position:absolute; top:7px; right:7px; width:22px; height:22px;
  border-radius:50%; background:#ed1c24;
  display:flex; align-items:center; justify-content:center;
  z-index:9; pointer-events:none; box-shadow:0 2px 6px rgba(0,0,0,.45);
}
.trakt-ms-badge svg { width:13px; height:13px; }

/* ── Toast ── */
.trakt-ms-toast {
  position:fixed; bottom:88px; left:50%;
  transform:translateX(-50%) translateY(16px);
  z-index:9004; background:#16162a; border:1px solid #444;
  border-radius:8px; padding:9px 18px; font-size:12px; color:#ececec;
  opacity:0; pointer-events:none; transition:opacity .22s, transform .22s;
  max-width:88vw; text-align:center; font-family:inherit;
}
.trakt-ms-toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
.trakt-ms-toast.success { border-color:#166534; color:#86efac; }
.trakt-ms-toast.error   { border-color:#991b1b; color:#fca5a5; }
.trakt-ms-toast.warning { border-color:#854d0e; color:#fde68a; }

/* ── Spinner ── */
@keyframes trakt-ms-spin { to { transform:rotate(360deg); } }
.trakt-ms-spin {
  display:inline-block; width:13px; height:13px;
  border:2px solid rgba(255,255,255,.25); border-top-color:#fff;
  border-radius:50%; animation:trakt-ms-spin .65s linear infinite;
  vertical-align:middle; margin-right:5px;
}
    `;
    document.head.appendChild(s);
  }

  // ─── FAB ──────────────────────────────────────────────────────────────────

  let fabEl;

  function buildFAB() {
    fabEl = document.createElement('button');
    fabEl.className = 'trakt-ms-fab';
    fabEl.title = 'Multiselect for Trakt';
    fabEl.innerHTML = ICON.fab;
    // ── Toggle: activate on first click, deactivate on second ──
    fabEl.addEventListener('click', () =>
      state.isSelectionMode ? exitSelectionMode() : enterSelectionMode(),
    );
    document.body.appendChild(fabEl);
  }

  function syncFAB() {
    fabEl?.classList.toggle('active', state.isSelectionMode);
  }

  // ─── Modal — one horizontal line ──────────────────────────────────────────

  let modalEl, countEl, pickerEl;

  function buildModal() {
    // Picker is outside the modal so it can float above it independently
    pickerEl = document.createElement('div');
    pickerEl.className = 'trakt-ms-list-picker';
    document.body.appendChild(pickerEl);

    modalEl = document.createElement('div');
    modalEl.className = 'trakt-ms-modal';

    // Order: Select Page | Unselect Page | divider | count | divider | Copy | Move | Delete
    modalEl.innerHTML = `
      <button class="trakt-ms-btn trakt-ms-btn-page"   id="ms-selpage"   title="Select Page">${ICON.selectPage}</button>
      <button class="trakt-ms-btn trakt-ms-btn-page"   id="ms-unselpage" title="Unselect Page">${ICON.unselectPage}</button>
      <div class="trakt-ms-divider"></div>
      <span class="trakt-ms-modal-count">0</span>
      <div class="trakt-ms-divider"></div>
      <button class="trakt-ms-btn trakt-ms-btn-action" id="ms-copy"   title="Copy"   disabled>${ICON.copy}</button>
      <button class="trakt-ms-btn trakt-ms-btn-action" id="ms-move"   title="Move"   disabled>${ICON.move}</button>
      <button class="trakt-ms-btn trakt-ms-btn-delete" id="ms-delete" title="Delete" disabled>${ICON.delete}</button>
    `;
    document.body.appendChild(modalEl);

    countEl = modalEl.querySelector('.trakt-ms-modal-count');

    modalEl.querySelector('#ms-selpage').onclick = selectPage;
    modalEl.querySelector('#ms-unselpage').onclick = unselectPage;
    modalEl.querySelector('#ms-copy').onclick = () => triggerCopyMove('copy');
    modalEl.querySelector('#ms-move').onclick = () => triggerCopyMove('move');
    modalEl.querySelector('#ms-delete').onclick = triggerDelete;
  }

  function openModal() {
    if (!modalEl) return;
    const ownerOnly = ['ms-copy', 'ms-move', 'ms-delete'];
    ownerOnly.forEach((id) => {
      const btn = modalEl.querySelector('#' + id);
      if (btn) btn.style.display = state.isOwner ? '' : 'none';
    });
    modalEl.classList.add('open');
    syncCount();
  }

  function closeModal() {
    modalEl?.classList.remove('open');
    if (pickerEl) {
      pickerEl.classList.remove('open');
      pickerEl.innerHTML = '';
    }
  }

  function syncCount() {
    if (!countEl) return;
    const n = state.selectedIds.size;
    countEl.textContent = n;
    ['ms-copy', 'ms-move', 'ms-delete'].forEach((id) => {
      const btn = modalEl?.querySelector('#' + id);
      if (btn) btn.disabled = n === 0;
    });
  }

  // ─── Toast ────────────────────────────────────────────────────────────────

  let toastEl, toastTimer;

  function buildToast() {
    toastEl = document.createElement('div');
    toastEl.className = 'trakt-ms-toast';
    document.body.appendChild(toastEl);
  }

  function toast(msg, type = 'success', ms = 3200) {
    if (!toastEl) return;
    clearTimeout(toastTimer);
    toastEl.textContent = msg;
    toastEl.className = `trakt-ms-toast ${type}`;
    void toastEl.offsetWidth;
    toastEl.classList.add('show');
    toastTimer = setTimeout(() => toastEl.classList.remove('show'), ms);
  }

  // ─── Selection logic ──────────────────────────────────────────────────────

  function enterSelectionMode() {
    state.isSelectionMode = true;
    document.body.classList.add('trakt-ms-selection-mode');
    syncFAB();
    openModal();
  }

  function exitSelectionMode() {
    state.isSelectionMode = false;
    state.selectedIds.clear();
    document.body.classList.remove('trakt-ms-selection-mode');
    refreshUI();
    closeModal();
    syncFAB();
  }

  function toggleItem(id) {
    state.selectedIds.has(id)
      ? state.selectedIds.delete(id)
      : state.selectedIds.add(id);
    refreshUI();
  }

  function selectPage() {
    state.visibleItems.forEach((_, id) => state.selectedIds.add(id));
    refreshUI();
  }

  function unselectPage() {
    state.visibleItems.forEach((_, id) => state.selectedIds.delete(id));
    refreshUI();
  }

  function refreshUI() {
    state.visibleItems.forEach((item, id) => {
      const sel = state.selectedIds.has(id);
      item.element.classList.toggle('trakt-ms-selected', sel);
      let badge = item.element.querySelector('.trakt-ms-badge');
      if (sel && !badge) {
        badge = document.createElement('span');
        badge.className = 'trakt-ms-badge';
        badge.innerHTML = CHECKMARK;
        item.element.style.position = item.element.style.position || 'relative';
        item.element.appendChild(badge);
      } else if (!sel && badge) {
        badge.remove();
      }
    });
    syncCount();
  }

  // ─── Card click capture ───────────────────────────────────────────────────

  function onCardClick(e) {
    if (!state.isSelectionMode) return;
    e.preventDefault();
    e.stopImmediatePropagation();
    const id = e.currentTarget.dataset.listItemId;
    if (id) toggleItem(id);
  }

  function bindCard(el) {
    el.addEventListener('click', onCardClick, true);
  }
  function unbindCard(el) {
    el.removeEventListener('click', onCardClick, true);
  }

  // ─── Item scanning ────────────────────────────────────────────────────────

  function scanItems() {
    state.visibleItems.forEach((item) => unbindCard(item.element));
    state.visibleItems.clear();
    document
      .querySelectorAll('#sortable-grid .grid-item[data-list-item-id]')
      .forEach((el) => {
        const item = normalizeItem(el);
        if (!item.listItemId) return;
        state.visibleItems.set(item.listItemId, item);
        bindCard(el);
      });
    refreshUI();
  }

  // ─── API client ───────────────────────────────────────────────────────────

  let _lastReq = 0;

  function apiRequest(method, path, body) {
    return new Promise((res, rej) => {
      const delay = Math.max(0, 250 - (Date.now() - _lastReq));
      setTimeout(() => {
        _lastReq = Date.now();
        const clientId = GM_getValue('clientId', '');
        const accessToken = GM_getValue('accessToken', '');
        if (!clientId)
          return rej(
            new Error(
              'API key not set. Open ⚙️ Configure from the script menu.',
            ),
          );

        GM_xmlhttpRequest({
          method,
          url: API_BASE + path,
          headers: {
            'Content-Type': 'application/json',
            'trakt-api-version': API_VERSION,
            'trakt-api-key': clientId,
            ...(accessToken ? { Authorization: 'Bearer ' + accessToken } : {}),
          },
          data: body ? JSON.stringify(body) : undefined,
          onload(r) {
            if (r.status === 429) {
              const wait = parseInt(
                r.responseHeaders?.match(/retry-after:\s*(\d+)/i)?.[1] ?? '3',
                10,
              );
              setTimeout(
                () => apiRequest(method, path, body).then(res).catch(rej),
                wait * 1000,
              );
              return;
            }
            if (r.status >= 200 && r.status < 300) {
              try {
                res(r.responseText ? JSON.parse(r.responseText) : null);
              } catch {
                res(null);
              }
            } else {
              rej(
                new Error(`API ${r.status}: ${r.responseText?.slice(0, 180)}`),
              );
            }
          },
          onerror(e) {
            rej(new Error('Network error: ' + JSON.stringify(e)));
          },
        });
      }, delay);
    });
  }

  const api = {
    getLists: (u) => apiRequest('GET', `/users/${u}/lists`),
    addItems: (u, s, p) =>
      apiRequest('POST', `/users/${u}/lists/${s}/items`, p),
    removeItems: (u, s, p) =>
      apiRequest('POST', `/users/${u}/lists/${s}/items/remove`, p),
  };

  // ─── Payload builder ──────────────────────────────────────────────────────

  function buildPayload(items) {
    const p = { movies: [], shows: [], seasons: [], episodes: [], people: [] };
    for (const item of items) {
      const id = item.traktId ? parseInt(item.traktId, 10) : NaN;
      switch (item.type) {
        case 'movie':
          if (!isNaN(id)) p.movies.push({ ids: { trakt: id } });
          break;
        case 'show':
          if (!isNaN(id)) p.shows.push({ ids: { trakt: id } });
          break;
        case 'season':
          if (!isNaN(id)) {
            p.seasons.push({ ids: { trakt: id } });
          } else {
            const m = item.url.match(/\/shows\/([^/]+)\/seasons\/(\d+)/);
            if (m)
              p.seasons.push({
                show: { ids: { slug: m[1] } },
                season: { number: +m[2] },
              });
          }
          break;
        case 'episode':
          if (!isNaN(id)) {
            p.episodes.push({ ids: { trakt: id } });
          } else {
            const m = item.url.match(
              /\/shows\/([^/]+)\/seasons\/(\d+)\/episodes\/(\d+)/,
            );
            if (m)
              p.episodes.push({
                show: { ids: { slug: m[1] } },
                episode: { season: +m[2], number: +m[3] },
              });
          }
          break;
        case 'person':
          if (!isNaN(id)) p.people.push({ ids: { trakt: id } });
          break;
      }
    }
    Object.keys(p).forEach((k) => {
      if (!p[k].length) delete p[k];
    });
    return p;
  }

  function sumResult(r, key) {
    if (!r?.[key]) return 0;
    return Object.values(r[key]).reduce((a, b) => a + (b | 0), 0);
  }
  function countNotFound(r) {
    if (!r?.not_found) return 0;
    return Object.values(r.not_found).flat().length;
  }

  function setLoading(btn, on) {
    if (!btn) return;
    if (on) {
      btn._html = btn.innerHTML;
      btn.innerHTML = `<span class="trakt-ms-spin"></span>`;
      btn.disabled = true;
    } else {
      btn.innerHTML = btn._html || btn.innerHTML;
      btn.disabled = state.selectedIds.size === 0;
    }
  }

  // ─── Action: Copy / Move ──────────────────────────────────────────────────

  async function triggerCopyMove(action) {
    const ctx = getCtx();
    if (!ctx) return;

    pickerEl.innerHTML = `<div class="trakt-ms-picker-item" style="color:#777;cursor:default">
      <span class="trakt-ms-spin"></span>Loading your lists…</div>`;
    pickerEl.classList.add('open');

    try {
      const lists = await api.getLists(ctx.username);
      pickerEl.innerHTML = '';
      const others = lists.filter((l) => l.ids?.slug !== ctx.listSlug);
      if (!others.length) {
        pickerEl.innerHTML = `<div class="trakt-ms-picker-item" style="color:#777;cursor:default">No other lists found.</div>`;
        return;
      }
      others.forEach((list) => {
        const row = document.createElement('div');
        row.className = 'trakt-ms-picker-item';
        row.textContent = list.name;
        row.onclick = () =>
          executeAction(action, ctx, list.ids.slug, list.name);
        pickerEl.appendChild(row);
      });
    } catch (err) {
      pickerEl.innerHTML = `<div class="trakt-ms-picker-item" style="color:#f87171;cursor:default">Error: ${err.message}</div>`;
    }
  }

  async function executeAction(action, ctx, targetSlug, targetName) {
    const selectedItems = [...state.selectedIds]
      .map((id) => state.visibleItems.get(id))
      .filter(Boolean);
    if (!selectedItems.length) return;

    pickerEl.classList.remove('open');
    pickerEl.innerHTML = '';

    const copyBtn = modalEl.querySelector('#ms-copy');
    const moveBtn = modalEl.querySelector('#ms-move');
    const deleteBtn = modalEl.querySelector('#ms-delete');
    setLoading(copyBtn, true);
    setLoading(moveBtn, true);
    setLoading(deleteBtn, true);

    const payload = buildPayload(selectedItems);

    try {
      const addResult = await api.addItems(ctx.username, targetSlug, payload);
      const added = sumResult(addResult, 'added');
      const notFound = countNotFound(addResult);

      if (action === 'move') {
        await api.removeItems(ctx.username, ctx.listSlug, payload);
        selectedItems.forEach((item) => {
          item.element.remove();
          state.selectedIds.delete(item.listItemId);
        });
        scanItems();
        toast(`Moved ${added} item(s) to "${targetName}"`, 'success');
      } else {
        const msg =
          notFound > 0
            ? `Copied ${added} item(s) to "${targetName}". ${notFound} not found (kept selected).`
            : `Copied ${added} item(s) to "${targetName}"`;
        toast(msg, notFound > 0 ? 'warning' : 'success');
        if (!notFound) {
          state.selectedIds.clear();
          refreshUI();
        }
      }
    } catch (err) {
      toast(
        `${action === 'move' ? 'Move' : 'Copy'} failed: ${err.message}`,
        'error',
        6000,
      );
    } finally {
      setLoading(copyBtn, false);
      setLoading(moveBtn, false);
      setLoading(deleteBtn, false);
    }
  }

  // ─── Action: Delete ───────────────────────────────────────────────────────

  async function triggerDelete() {
    const n = state.selectedIds.size;
    const ctx = getCtx();
    if (!n || !ctx) return;

    if (
      !confirm(
        `Delete ${n} selected item(s) from this list?\nThis cannot be undone.`,
      )
    )
      return;

    const selectedItems = [...state.selectedIds]
      .map((id) => state.visibleItems.get(id))
      .filter(Boolean);
    const deleteBtn = modalEl.querySelector('#ms-delete');
    setLoading(deleteBtn, true);

    const payload = buildPayload(selectedItems);

    try {
      const result = await api.removeItems(ctx.username, ctx.listSlug, payload);
      const deleted = sumResult(result, 'deleted');
      const notFound = countNotFound(result);

      selectedItems.forEach((item) => {
        state.selectedIds.delete(item.listItemId);
        item.element.remove();
      });
      scanItems();

      toast(
        notFound > 0
          ? `Deleted ${deleted} item(s). ${notFound} not found.`
          : `Deleted ${deleted} item(s).`,
        notFound > 0 ? 'warning' : 'success',
      );
    } catch (err) {
      toast(`Delete failed: ${err.message}`, 'error', 6000);
    } finally {
      setLoading(deleteBtn, false);
    }
  }

  // ─── OAuth — device-code flow ─────────────────────────────────────────────

  function startOAuth() {
    const clientId = GM_getValue('clientId', '');
    const clientSecret = GM_getValue('clientSecret', '');
    if (!clientId || !clientSecret) {
      alert(
        'Set your Client ID and Client Secret first (⚙️ Configure Multiselect for Trakt).',
      );
      return;
    }
    GM_xmlhttpRequest({
      method: 'POST',
      url: API_BASE + '/oauth/device/code',
      headers: { 'Content-Type': 'application/json' },
      data: JSON.stringify({ client_id: clientId }),
      onload(r) {
        if (r.status !== 200) {
          alert('Device code request failed: ' + r.status);
          return;
        }
        deviceCodeDialog(JSON.parse(r.responseText), clientId, clientSecret);
      },
      onerror() {
        alert('Network error during OAuth.');
      },
    });
  }

  function deviceCodeDialog(data, clientId, clientSecret) {
    const { device_code, user_code, verification_url, expires_in, interval } =
      data;
    const overlay = document.createElement('div');
    overlay.style.cssText =
      'position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:99999;display:flex;align-items:center;justify-content:center;';
    overlay.innerHTML = `
      <div style="background:#16162a;border:1px solid #383860;border-radius:14px;padding:30px 36px;max-width:400px;color:#ececec;text-align:center;">
        <h3 style="margin:0 0 12px;color:#ed1c24;font-size:16px;">Multiselect for Trakt — Authorize</h3>
        <p style="margin:0 0 14px;font-size:13px;color:#999;">
          Visit <a href="${verification_url}" target="_blank" style="color:#ed1c24">${verification_url}</a> and enter:
        </p>
        <div style="font-size:30px;font-weight:700;letter-spacing:7px;background:#0d0d1a;padding:12px 20px;border-radius:8px;margin-bottom:16px;">${user_code}</div>
        <p id="ms-oauth-status" style="font-size:12px;color:#777;min-height:16px;">Waiting for authorization…</p>
        <button onclick="this.closest('[style]').remove()" style="margin-top:14px;padding:7px 20px;background:#2c2c50;color:#ccc;border:none;border-radius:7px;cursor:pointer;">Cancel</button>
      </div>
    `;
    document.body.appendChild(overlay);

    let elapsed = 0;
    const poll = setInterval(() => {
      elapsed += interval;
      if (elapsed > expires_in) {
        clearInterval(poll);
        overlay.querySelector('#ms-oauth-status').textContent =
          'Code expired. Try again.';
        return;
      }
      GM_xmlhttpRequest({
        method: 'POST',
        url: API_BASE + '/oauth/device/token',
        headers: { 'Content-Type': 'application/json' },
        data: JSON.stringify({
          code: device_code,
          client_id: clientId,
          client_secret: clientSecret,
        }),
        onload(r) {
          if (r.status === 200) {
            clearInterval(poll);
            const t = JSON.parse(r.responseText);
            GM_setValue('accessToken', t.access_token);
            GM_setValue('refreshToken', t.refresh_token);
            GM_setValue('tokenExpiry', Date.now() + t.expires_in * 1000);
            overlay.remove();
            toast(
              'Authorized! Copy, Move, and Delete are now active.',
              'success',
              4000,
            );
          } else if (r.status === 410 || r.status === 418) {
            clearInterval(poll);
            overlay.querySelector('#ms-oauth-status').textContent =
              r.status === 410 ? 'Code expired.' : 'Authorization denied.';
          }
        },
      });
    }, interval * 1000);
  }

  // ─── Config modal ─────────────────────────────────────────────────────────

  function showConfig() {
    const old = document.getElementById('ms-config-overlay');
    if (old) {
      old.remove();
      return;
    }

    const overlay = document.createElement('div');
    overlay.id = 'ms-config-overlay';
    overlay.style.cssText =
      'position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:99999;display:flex;align-items:center;justify-content:center;';
    overlay.innerHTML = `
      <div style="background:#16162a;border:1px solid #383860;border-radius:14px;padding:28px 32px;max-width:430px;width:90%;color:#ececec;">
        <h3 style="margin:0 0 14px;color:#ed1c24;font-size:15px;">⚙️ Multiselect for Trakt — Configure</h3>
        <p style="font-size:12px;color:#888;margin:0 0 14px;">
          Create an app at <a href="https://trakt.tv/oauth/applications/new" target="_blank"
          style="color:#ed1c24">trakt.tv/oauth/applications/new</a>.<br>
          Set redirect URI to <code style="background:#0d0d1a;padding:1px 5px;border-radius:3px;">urn:ietf:wg:oauth:2.0:oob</code>.
        </p>
        <label style="font-size:12px;color:#bbb;display:block;margin-bottom:4px;">Client ID (API Key)</label>
        <input id="ms-cfg-id" type="text" placeholder="abc123…"
          value="${GM_getValue('clientId', '')}"
          style="width:100%;box-sizing:border-box;padding:8px 10px;background:#0d0d1a;border:1px solid #383860;border-radius:6px;color:#fff;font-size:12px;margin-bottom:12px;">
        <label style="font-size:12px;color:#bbb;display:block;margin-bottom:4px;">Client Secret</label>
        <input id="ms-cfg-sec" type="password" placeholder="secret…"
          value="${GM_getValue('clientSecret', '')}"
          style="width:100%;box-sizing:border-box;padding:8px 10px;background:#0d0d1a;border:1px solid #383860;border-radius:6px;color:#fff;font-size:12px;margin-bottom:18px;">
        <div style="display:flex;gap:8px;flex-wrap:wrap;">
          <button id="ms-cfg-save"  style="padding:8px 18px;background:#ed1c24;color:#fff;border:none;border-radius:7px;cursor:pointer;font-weight:700;font-size:12px;">Save</button>
          <button id="ms-cfg-auth"  style="padding:8px 18px;background:#252550;color:#ccc;border:none;border-radius:7px;cursor:pointer;font-weight:700;font-size:12px;">Authorize…</button>
          <button id="ms-cfg-close" style="padding:8px 14px;background:transparent;color:#777;border:1px solid #38385a;border-radius:7px;cursor:pointer;font-size:12px;margin-left:auto;">Close</button>
        </div>
        <p id="ms-cfg-status" style="font-size:11px;color:#888;margin-top:10px;min-height:14px;"></p>
      </div>
    `;
    document.body.appendChild(overlay);

    overlay.querySelector('#ms-cfg-save').onclick = () => {
      GM_setValue('clientId', overlay.querySelector('#ms-cfg-id').value.trim());
      GM_setValue(
        'clientSecret',
        overlay.querySelector('#ms-cfg-sec').value.trim(),
      );
      const s = overlay.querySelector('#ms-cfg-status');
      s.textContent = '✓ Saved.';
      s.style.color = '#86efac';
    };
    overlay.querySelector('#ms-cfg-auth').onclick = () => {
      overlay.remove();
      startOAuth();
    };
    overlay.querySelector('#ms-cfg-close').onclick = () => overlay.remove();
  }

  // ─── MutationObserver ─────────────────────────────────────────────────────

  function watchGrid(grid) {
    if (state.observer) state.observer.disconnect();
    state.observer = new MutationObserver(() => {
      clearTimeout(state.debounceTimer);
      state.debounceTimer = setTimeout(() => {
        const newKey = getPageKey();
        if (newKey !== state.currentPageKey) {
          state.selectedIds.clear();
          state.currentPageKey = newKey;
        }
        state.isOwner = detectOwner();
        scanItems();
      }, DEBOUNCE_MS);
    });
    state.observer.observe(grid, { childList: true, subtree: false });
  }

  // ─── Keyboard ─────────────────────────────────────────────────────────────

  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && state.isSelectionMode) exitSelectionMode();
  });

  // ─── Boot ─────────────────────────────────────────────────────────────────

  async function boot() {
    if (!getCtx()) return;

    injectCSS();
    buildFAB();
    buildModal();
    buildToast();

    GM_registerMenuCommand('⚙️ Configure Multiselect for Trakt', showConfig);
    GM_registerMenuCommand('🔑 Re-authorize with Trakt', startOAuth);

    try {
      const grid = await waitForEl('#sortable-grid');
      state.currentPageKey = getPageKey();
      state.isOwner = detectOwner();
      scanItems();
      watchGrid(grid);

      if (!GM_getValue('clientId', '')) {
        setTimeout(
          () =>
            toast(
              'Multiselect for Trakt installed. Open the script menu (⚙️) to configure your API key.',
              'warning',
              7000,
            ),
          1800,
        );
      }
    } catch (err) {
      console.warn('[Multiselect for Trakt] Boot error:', err.message);
    }
  }

  boot();
})();