Bulk-select, copy, move, and delete items on Trakt list pages
// ==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();
})();