Watch movies on IMDB, TMDB, Kinopoisk and Letterboxd! (+ Pro Search, Cache, Settings)
// ==UserScript==
// @name Tape Operator
// @namespace tape-operator
// @author Kirlovon + Max Letov
// @description Watch movies on IMDB, TMDB, Kinopoisk and Letterboxd! (+ Pro Search, Cache, Settings)
// @version 3.3.5
// @icon https://github.com/Kirlovon/Tape-Operator/raw/main/assets/favicon.png
// @run-at document-idle
// @grant GM.info
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @grant GM.openInTab
// @grant GM.xmlHttpRequest
// @grant GM_registerMenuCommand
// @match *://www.kinopoisk.ru/*
// @match *://hd.kinopoisk.ru/*
// @match *://*.imdb.com/title/*
// @match *://www.themoviedb.org/movie/*
// @match *://www.themoviedb.org/tv/*
// @match *://letterboxd.com/film/*
// @match *://tapeop.dev/*
// @connect api.themoviedb.org
// @connect lingering-salad-a373.l3towm.workers.dev
// ==/UserScript==
(async function () {
'use strict';
const VERSION = GM.info?.script?.version || '5.0.0';
const PLAYER_URL = 'https://tapeop.dev/';
const BUTTON_ID = 'tape-operator-button';
const TMDB_API_KEY = '5fc153497d26350515c189f71fb16ec0';
const TMDB_PROXY_BASE = 'https://lingering-salad-a373.l3towm.workers.dev/3';
const PROXY_ROOT = 'https://lingering-salad-a373.l3towm.workers.dev';
let openInNewTab = await GM.getValue('openInNewTab', true);
let forceLang = await GM.getValue('forceLang', 'auto');
const KINOPOISK_MATCHER = /kinopoisk\.ru\/(film|series)\/.*/;
const IMDB_MATCHER = /imdb\.com\/title\/tt\.*/;
const TMDB_MATCHER = /themoviedb\.org\/(movie|tv)\/\.*/;
const LETTERBOXD_MATCHER = /letterboxd\.com\/film\/\.*/;
const MATCHERS = [KINOPOISK_MATCHER, IMDB_MATCHER, TMDB_MATCHER, LETTERBOXD_MATCHER];
const i18n = {
'ru-RU': {
watchBtn: 'Смотреть онлайн',
searchPlaceholder: 'Поиск фильмов и сериалов...',
notFound: 'В базе не найдено',
apiError: 'Ошибка API (Проверь сеть)',
tvShow: 'Сериал', movie: 'Фильм', trailer: 'Трейлер 🎬',
extLink: 'Кинопоиск ↗', searchExt: '🔍 Искать "{query}" на Кинопоиске →'
},
'en-US': {
watchBtn: 'Watch online',
searchPlaceholder: 'Search for movies and TV shows...',
notFound: 'Not found in database',
apiError: 'API Error (Check network)',
tvShow: 'TV Show', movie: 'Movie', trailer: 'Trailer 🎬',
extLink: 'IMDb ↗', searchExt: '🔍 Search "{query}" on IMDb →'
}
};
const systemLang = (navigator.language || navigator.userLanguage).startsWith('en') ? 'en-US' : 'ru-RU';
let currentSearchLang = forceLang === 'auto' ? systemLang : forceLang;
let previousUrl = '/';
let searchCache = new Map();
let selectedIndex = -1;
const logger = {
info: (...args) => console.info('[Tape Operator]', ...args),
error: (...args) => console.error('[Tape Operator]', ...args),
};
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments, context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
}
function initMenu() {
GM_registerMenuCommand(`⚙️ Открывать плеер в новой вкладке: ${openInNewTab ? 'ВКЛ' : 'ВЫКЛ'}`, async () => {
openInNewTab = !openInNewTab;
await GM.setValue('openInNewTab', openInNewTab);
location.reload();
});
GM_registerMenuCommand(`🌐 Язык по умолчанию: ${forceLang.toUpperCase()}`, async () => {
const next = forceLang === 'auto' ? 'ru-RU' : (forceLang === 'ru-RU' ? 'en-US' : 'auto');
await GM.setValue('forceLang', next);
location.reload();
});
}
initMenu();
if (location.href.includes('tapeop.dev')) {
initPlayer();
} else {
initButtonLogic();
}
function initButtonLogic() {
const throttledUpdate = throttle(() => updateButton(), 200);
const observer = new MutationObserver(throttledUpdate);
observer.observe(document, { subtree: true, childList: true });
setInterval(() => checkButtonPositions(), 500);
updateButton();
}
function updateButton() {
const url = getCurrentURL();
if (url !== previousUrl) { document.getElementById(BUTTON_ID)?.remove(); }
if (!MATCHERS.some((m) => url.match(m))) return removeButton();
if (document.getElementById(BUTTON_ID)) { previousUrl = url; return; }
if (!extractTitle()) return removeButton();
previousUrl = url;
createAndAttachButton();
}
function checkButtonPositions() {
const url = getCurrentURL();
const btn = document.getElementById(BUTTON_ID);
if (!btn) return;
if (url.includes('imdb.com')) fixImdbPosition(btn);
else if (url.includes('hd.kinopoisk.ru')) fixHdKinopoiskPosition(btn);
}
function fixImdbPosition(btn) {
const targetBtn = document.querySelector('button.ipc-split-button__btn.ipc-split-button__btn--button-radius');
if (targetBtn && btn.nextElementSibling !== targetBtn.parentElement) {
targetBtn.parentElement.parentElement.insertBefore(btn, targetBtn.parentElement);
btn.className = 'tape-op-base tape-op-yellow'; btn.classList.remove('tape-op-fixed');
btn.style.width = '100%'; btn.style.marginBottom = '12px'; btn.style.marginRight = '0';
}
}
function fixHdKinopoiskPosition(btn) {
const trailerBtn = document.querySelector('button[class*="styles_button_trailer"]');
if (trailerBtn && btn.nextElementSibling !== trailerBtn) {
trailerBtn.parentElement.insertBefore(btn, trailerBtn);
btn.className = 'tape-op-base tape-op-orange'; btn.classList.remove('tape-op-fixed');
btn.style.width = 'auto'; btn.style.marginBottom = '0'; btn.style.marginLeft = '0'; btn.style.marginRight = '12px';
}
}
function createAndAttachButton() {
if (document.getElementById(BUTTON_ID)) return;
injectStyles();
const url = getCurrentURL();
const isImdb = url.includes('imdb.com');
const btn = document.createElement('div');
btn.id = BUTTON_ID;
btn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg><span>${i18n[currentSearchLang].watchBtn}</span>`;
btn.addEventListener('click', () => openPlayerFromSite());
btn.addEventListener('mousedown', (e) => e.button === 1 && openPlayerFromSite(true));
btn.className = `tape-op-base ${isImdb ? 'tape-op-yellow' : 'tape-op-orange'}`;
if (url.includes('kinopoisk.ru')) {
const buttonsContainer = document.querySelector('[class*="styles_buttonsContainer"]');
const foldersButton = document.querySelector('[class^="styles_foldersButton"]');
if (buttonsContainer) buttonsContainer.prepend(btn);
else if (foldersButton?.parentElement) foldersButton.parentElement.prepend(btn);
else { btn.classList.add('tape-op-fixed'); document.body.appendChild(btn); }
} else if (url.includes('letterboxd.com')) {
const actions = document.querySelector('.sidebar');
if (actions) { btn.className = 'tape-op-base tape-op-orange'; actions.prepend(btn); }
else { btn.classList.add('tape-op-fixed'); document.body.appendChild(btn); }
} else {
btn.classList.add('tape-op-fixed'); document.body.appendChild(btn);
}
}
function removeButton() { document.getElementById(BUTTON_ID)?.remove(); }
async function openPlayerFromSite(forceBg = false) {
const data = extractMovieData();
if (!data) return logger.error('Failed to extract movie data');
await GM.setValue('movie-data', data);
GM.openInTab(PLAYER_URL, forceBg || openInNewTab);
}
function initSearchLogic() {
if (document.getElementById('kp-search-container')) return;
injectStyles();
const container = document.createElement('div'); container.id = 'kp-search-container';
const inputWrapper = document.createElement('div'); inputWrapper.id = 'kp-search-input-wrapper';
const input = document.createElement('input');
input.id = 'kp-search-input';
input.placeholder = i18n[currentSearchLang].searchPlaceholder;
input.autocomplete = 'off';
const clearBtn = document.createElement('div');
clearBtn.id = 'kp-clear-btn'; clearBtn.innerHTML = '✕'; clearBtn.style.display = 'none';
const langToggle = document.createElement('button');
langToggle.id = 'kp-lang-toggle';
langToggle.innerText = currentSearchLang === 'ru-RU' ? 'RU' : 'EN';
const resultsList = document.createElement('div'); resultsList.id = 'kp-search-results';
inputWrapper.appendChild(input);
inputWrapper.appendChild(clearBtn);
inputWrapper.appendChild(langToggle);
container.appendChild(inputWrapper);
container.appendChild(resultsList);
document.body.appendChild(container);
let debounceTimer;
clearBtn.addEventListener('click', () => {
input.value = ''; input.focus();
resultsList.style.display = 'none'; clearBtn.style.display = 'none';
});
langToggle.addEventListener('click', () => {
currentSearchLang = currentSearchLang === 'ru-RU' ? 'en-US' : 'ru-RU';
langToggle.innerText = currentSearchLang === 'ru-RU' ? 'RU' : 'EN';
input.placeholder = i18n[currentSearchLang].searchPlaceholder;
const query = input.value.trim();
if (query.length >= 2) searchTMDB(query, resultsList, input);
});
input.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
const query = e.target.value.trim();
clearBtn.style.display = query.length > 0 ? 'block' : 'none';
if (query.length < 2) { resultsList.style.display = 'none'; return; }
showLoading(resultsList);
debounceTimer = setTimeout(() => searchTMDB(query, resultsList, input), 300);
});
input.addEventListener('keydown', (e) => {
const items = resultsList.querySelectorAll('.kp-result-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
updateSelection(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
updateSelection(items);
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && items[selectedIndex]) {
items[selectedIndex].click();
} else {
const query = input.value.trim();
if (query) searchTMDB(query, resultsList, input);
}
}
});
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) resultsList.style.display = 'none';
});
}
function updateSelection(items) {
items.forEach(i => i.classList.remove('selected'));
if (selectedIndex >= 0 && items[selectedIndex]) {
items[selectedIndex].classList.add('selected');
items[selectedIndex].scrollIntoView({ block: 'nearest' });
}
}
function showLoading(container) {
container.innerHTML = '<div class="kp-spinner-container"><div class="kp-spinner"></div></div>';
container.style.display = 'block';
}
async function searchTMDB(query, resultsList, input) {
selectedIndex = -1;
const cacheKey = `${currentSearchLang}_${query}`;
if (searchCache.has(cacheKey)) {
renderResults(searchCache.get(cacheKey), query, resultsList);
return;
}
const url = `${TMDB_PROXY_BASE}/search/multi?api_key=${TMDB_API_KEY}&language=${currentSearchLang}&query=${encodeURIComponent(query)}&page=1&include_adult=false`;
try {
const res = await new Promise((resolve, reject) => GM.xmlHttpRequest({
method: "GET", url: url,
onload: (res) => res.status === 200 ? resolve(res.responseText) : reject(),
onerror: reject
}));
const data = JSON.parse(res);
searchCache.set(cacheKey, data.results);
renderResults(data.results, query, resultsList);
} catch (e) {
resultsList.innerHTML = `<div class="kp-status-msg">${i18n[currentSearchLang].apiError}</div>`;
}
}
function renderResults(movies, query, resultsList) {
resultsList.innerHTML = '';
const filtered = (movies || []).filter(m => m.media_type === 'movie' || m.media_type === 'tv');
if (!filtered.length) {
resultsList.innerHTML = `<div class="kp-status-msg">${i18n[currentSearchLang].notFound}</div>`;
addFallbackButton(query, resultsList);
return;
}
let count = 0;
filtered.forEach(movie => {
if (count >= 6) return; count++;
const item = document.createElement('div'); item.className = 'kp-result-item';
const title = movie.title || movie.name || '???';
const year = (movie.release_date || movie.first_air_date || '').split('-')[0];
const poster = movie.poster_path ? `${PROXY_ROOT}/t/p/w92${movie.poster_path}` : 'https://via.placeholder.com/44x66/333/888?text=?';
const ratingVal = movie.vote_average || 0;
const ratingText = ratingVal ? ratingVal.toFixed(1) : '';
const hue = Math.max(0, Math.min(120, ratingVal * 12));
const typeText = movie.media_type === 'tv' ? i18n[currentSearchLang].tvShow : i18n[currentSearchLang].movie;
item.innerHTML = `
<img src="${poster}" class="kp-poster">
<div class="kp-info"><div class="kp-title">${title}</div><div class="kp-meta">${year} • ${typeText}</div></div>
${ratingText ? `<div class="kp-rating" style="color: hsl(${hue}, 85%, 50%)">${ratingText}</div>` : ''}
`;
item.addEventListener('click', async () => {
await GM.setValue('movie-data', { tmdb: movie.id, title: title });
window.location.href = PLAYER_URL;
});
const actionsBlock = document.createElement('div');
actionsBlock.className = 'kp-actions-block';
const trailerBtn = document.createElement('div');
trailerBtn.className = 'kp-external-btn trailer-btn';
trailerBtn.innerHTML = i18n[currentSearchLang].trailer;
trailerBtn.addEventListener('click', (e) => {
e.stopPropagation();
window.open(`https://www.youtube.com/results?search_query=${encodeURIComponent(title + ' ' + year + ' trailer')}`, '_blank');
});
const extBtn = document.createElement('div');
extBtn.className = 'kp-external-btn';
extBtn.innerHTML = i18n[currentSearchLang].extLink;
extBtn.addEventListener('click', (e) => {
e.stopPropagation();
const qUrl = currentSearchLang === 'ru-RU'
? `https://www.kinopoisk.ru/index.php?kp_query=${encodeURIComponent(title + (year ? ' ' + year : ''))}`
: `https://www.imdb.com/find/?q=${encodeURIComponent(title + (year ? ' ' + year : ''))}`;
window.open(qUrl, '_blank');
});
actionsBlock.appendChild(trailerBtn);
actionsBlock.appendChild(extBtn);
item.appendChild(actionsBlock);
resultsList.appendChild(item);
});
addFallbackButton(query, resultsList);
}
function addFallbackButton(query, list) {
const btn = document.createElement('div'); btn.className = 'kp-fallback-btn';
btn.innerHTML = i18n[currentSearchLang].searchExt.replace('{query}', query);
btn.onclick = () => window.open(currentSearchLang === 'ru-RU'
? `https://www.kinopoisk.ru/index.php?kp_query=${encodeURIComponent(query)}`
: `https://www.imdb.com/find/?q=${encodeURIComponent(query)}`, '_blank');
list.appendChild(btn);
}
async function initPlayer() {
const data = await GM.getValue('movie-data', {});
await GM.deleteValue('movie-data');
if (!data || Object.keys(data).length === 0) { initSearchLogic(); return; }
const scriptElement = document.createElement('script');
scriptElement.innerHTML = `globalThis.init(JSON.parse(${JSON.stringify(JSON.stringify(data))}), "${VERSION}");`;
document.body.appendChild(scriptElement);
initSearchLogic();
}
function extractMovieData() {
const url = getCurrentURL(), title = extractTitle();
if (!title) return null;
if (url.match(KINOPOISK_MATCHER)) {
if (url.includes('hd.kinopoisk.ru')) {
try {
const apolloState = Object.values(JSON.parse(document.getElementById('__NEXT_DATA__').innerText)?.props?.pageProps?.apolloState?.data || {});
const id = apolloState.find(i => i?.__typename === 'TvSeries' || i?.__typename === 'Film')?.id;
return id ? { kinopoisk: id, title } : null;
} catch(e) { return null; }
}
return { kinopoisk: url.split('/').at(4), title };
}
if (url.match(IMDB_MATCHER)) {
const sb = document.querySelector('a[data-testid="hero-title-block__series-link"]');
return { imdb: (sb ? sb.href : url).split('/').at(4), title };
}
if (url.match(TMDB_MATCHER)) return { tmdb: url.split('/').at(4).split('-')[0], title };
if (url.match(LETTERBOXD_MATCHER)) {
const links = Array.from(document.querySelectorAll('a'));
const imdb = links.find(l => l?.href?.match(IMDB_MATCHER))?.href?.split('/').at(4);
if (imdb) return { imdb, title };
const tmdb = links.find(l => l?.href?.match(TMDB_MATCHER))?.href?.split('/').at(4)?.split('-')[0];
if (tmdb) return { tmdbId: tmdb, title };
}
return null;
}
function getCurrentURL() { return location.origin + location.pathname; }
function extractTitle() {
try {
const el = document.querySelector('meta[property="og:title"]') || document.querySelector('meta[name="twitter:title"]');
if (!el) return null;
let t = el.content.trim();
if (t.startsWith('Кинопоиск.')) return null;
t = t.replace('— смотреть онлайн в хорошем качестве — Кинопоиск', '').trim();
if (t.includes('⭐')) return t.split('⭐')[0].trim();
if (t.endsWith('- IMDb') && t.includes(')')) return t.slice(0, t.lastIndexOf(')') + 1).trim();
return t;
} catch(e) { return null; }
}
function injectStyles() {
if (document.getElementById('tape-op-styles')) return;
const s = document.createElement('style'); s.id = 'tape-op-styles';
s.textContent = `
.tape-op-base { display: flex; align-items: center; justify-content: center; box-sizing: border-box; height: 52px; border-radius: 26px; font-family: 'Graphik LC', sans-serif; font-weight: 700; font-size: 15px; line-height: 20px; padding: 0 24px; cursor: pointer; border: none; text-decoration: none !important; transition: all 0.2s; z-index: 9999; margin-bottom: 10px; }
.tape-op-base:hover { transform: scale(1.02); } .tape-op-base svg { margin-right: 8px; width: 24px; height: 24px; }
.tape-op-orange { background: linear-gradient(90deg, #ff5b35, #ff9e22); color: #fff; box-shadow: 0 4px 12px rgba(255,91,53,0.25); display: inline-flex; }
.tape-op-orange:hover { box-shadow: 0 6px 16px rgba(255,91,53,0.4); } .tape-op-orange svg { fill: #fff; }
.tape-op-yellow { background: #F5C518; color: #000; box-shadow: 0 2px 6px rgba(0,0,0,0.15); } .tape-op-yellow svg { fill: #000; }
.tape-op-fixed { position: fixed; bottom: 20px; right: 20px; width: auto !important; z-index: 2147483647; }
#kp-search-container { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); width: 550px; z-index: 2147483647; font-family: sans-serif; }
#kp-search-input-wrapper { position: relative; width: 100%; display: flex; align-items: center; }
#kp-search-input { width: 100%; padding: 14px 100px 14px 24px; border-radius: 28px; border: 1px solid rgba(255,255,255,0.15); background: rgba(18,18,18,0.95); color: #fff; font-size: 16px; outline: none; box-shadow: 0 8px 32px rgba(0,0,0,0.8); backdrop-filter: blur(12px); transition: all 0.2s; }
#kp-search-input:focus { background: #191919; border-color: #ff6633; box-shadow: 0 8px 40px rgba(255,102,51,0.3); }
#kp-clear-btn { position: absolute; right: 65px; color: #888; font-size: 18px; cursor: pointer; padding: 10px; line-height: 1; transition: color 0.2s; }
#kp-clear-btn:hover { color: #fff; }
#kp-lang-toggle { position: absolute; right: 12px; background: rgba(255,102,51,0.15); border: 1px solid rgba(255,102,51,0.5); color: #ff6633; border-radius: 16px; padding: 6px 12px; font-size: 13px; font-weight: bold; cursor: pointer; transition: all 0.2s; user-select: none; }
#kp-lang-toggle:hover { background: #ff6633; color: #fff; }
#kp-search-results { margin-top: 12px; background: rgba(25,25,25,0.98); border-radius: 16px; overflow: hidden; display: none; box-shadow: 0 10px 50px rgba(0,0,0,0.95); max-height: 500px; overflow-y: auto; border: 1px solid rgba(255,255,255,0.05); }
.kp-result-item { display: flex; align-items: center; padding: 12px 16px; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.05); transition: background 0.15s; }
.kp-result-item:last-child { border-bottom: none; }
.kp-result-item:hover, .kp-result-item.selected { background: rgba(255,255,255,0.15); }
.kp-poster { width: 44px; height: 66px; object-fit: cover; border-radius: 6px; margin-right: 16px; background: #333; flex-shrink: 0; }
.kp-info { display: flex; flex-direction: column; overflow: hidden; flex: 1; }
.kp-title { font-weight: 600; color: #eee; font-size: 15px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kp-meta { color: #aaa; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kp-rating { font-weight: 700; margin: 0 12px; font-size: 14px; }
.kp-actions-block { display: flex; flex-direction: column; gap: 4px; }
.kp-external-btn { padding: 4px 8px; border-radius: 6px; background: rgba(255,255,255,0.05); color: #888; font-size: 11px; font-weight: 600; border: 1px solid rgba(255,255,255,0.1); transition: all 0.2s; white-space: nowrap; cursor: pointer; text-align: center; }
.kp-external-btn:hover { background: #ff6633; color: #fff; border-color: #ff6633; }
.trailer-btn:hover { background: #ff0000; border-color: #ff0000; color: #fff; }
.kp-status-msg { padding: 15px; text-align: center; color: #888; font-size: 14px; }
.kp-fallback-btn { display: block; width: 100%; padding: 15px; text-align: center; background: #222; color: #ff6633; text-decoration: none; font-weight: 600; cursor: pointer; border-top: 1px solid rgba(255,255,255,0.1); }
.kp-fallback-btn:hover { background: #333; }
#kp-search-results::-webkit-scrollbar { width: 6px; }
#kp-search-results::-webkit-scrollbar-track { background: transparent; }
#kp-search-results::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
.kp-spinner-container { padding: 20px; display: flex; justify-content: center; }
.kp-spinner { width: 24px; height: 24px; border: 3px solid rgba(255,255,255,0.1); border-top: 3px solid #ff6633; border-radius: 50%; animation: kp-spin 1s linear infinite; }
@keyframes kp-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
`;
document.head.appendChild(s);
}
})();