Greasy Fork is available in English.

Multiselect for Trakt

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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==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();
})();