Tape Operator

Watch movies on IMDB, TMDB, Kinopoisk and Letterboxd!

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name            Tape Operator
// @namespace       tape-operator
// @author          Kirlovon
// @description     Watch movies on IMDB, TMDB, Kinopoisk and Letterboxd!
// @version         3.3.3
// @icon            https://github.com/Kirlovon/Tape-Operator/raw/main/assets/favicon.png
// @resource        BANNER_IMAGE https://cdn.jsdelivr.net/gh/Kirlovon/Tape-Operator@main/assets/banner.webp
// @run-at          document-idle
// @grant           GM.info
// @grant           GM.setValue
// @grant           GM.getValue
// @grant           GM.openInTab
// @grant           GM.deleteValue
// @grant           GM_getResourceURL
// @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/*
// ==/UserScript==

(function () {

	// Current version of the script
	const VERSION = GM.info?.script?.version;

	// Banner image
	// Violentmonkey: false forces data URL to avoid CSP issues with blob
	const BANNER_IMAGE = GM_getResourceURL('BANNER_IMAGE', false);

	// URL to the player
	const PLAYER_URL = 'https://tapeoperator.surge.sh/'; 

	// ID of the banner, attached to the page
	const BANNER_ID = 'tape-operator-banner';

	// URL Matchers
	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];

	// Logging utility
	const logger = {
		info: (...args) => console.info('[Tape Operator Script]', ...args),
		warn: (...args) => console.warn('[Tape Operator Script]', ...args),
		error: (...args) => console.error('[Tape Operator Script]', ...args),
	}

	let previousUrl = '/';

	/**
	 * Initialize banner on the page
	 */
	async function initBanner() {
		const observer = new MutationObserver(() => updateBanner());
		observer.observe(document, { subtree: true, childList: true });
		updateBanner();
	}

	/**
	 * Update banner based on the current movie data on page
	 */
	function updateBanner() {
		const url = getCurrentURL();

		// Skip to prevent unnecessary updates
		if (url === previousUrl) return;

		// Check if URL matches
		const urlMatches = MATCHERS.some((matcher) => url.match(matcher));
		if (!urlMatches) return removeBanner();

		// Check if title is present
		const extractedTitle = extractTitle();
		if (!extractedTitle) return removeBanner();

		// Movie found, now we can stop searching
		previousUrl = url;
		attachBanner();
	}

	/**
	 * Extract movie data from the page
	 */
	function extractMovieData() {
		const url = getCurrentURL();

		// Movie title
		const title = extractTitle();
		if (!title) return null;

		// Kinopoisk ID
		if (url.match(KINOPOISK_MATCHER)) {

			// If its a Kinopoisk HD page
			if (url.includes('hd.kinopoisk.ru')) {
				try {
					const element = document.getElementById('__NEXT_DATA__');
					const jsonData = JSON.parse(element.innerText);
					const apolloState = Object.values(jsonData?.props?.pageProps?.apolloState?.data || {});

					const id = apolloState.find((item) => item?.__typename === 'TvSeries' || item?.__typename === 'Film')?.id;
					if (!id) throw new Error('No ID was found in the page data');

					return { kinopoisk: id, title };
				} catch (error) {
					console.error('Failed to extract ID from Kinopoisk HD page:', error);
					return null;
				}
			}

			const id = url.split('/').at(4);
			return { kinopoisk: id, title };
		}

		// IMDB ID
		if (url.match(IMDB_MATCHER)) {
			const seriesBlock = document.querySelector('a[data-testid="hero-title-block__series-link"]');

			// In case of opened episode of the series, get ID from "Go back to series" link
			if (seriesBlock) {
				const id = seriesBlock.href.split('/').at(4);
				return { imdb: id, title };
			}

			const id = url.split('/').at(4);
			return { imdb: id, title };
		}

		// TMDB ID
		if (url.match(TMDB_MATCHER)) {
			const id = url.split('/').at(4).split('-').at(0);
			return { tmdb: id, title };
		}

		// IMDB ID from Letterboxd
		if (url.match(LETTERBOXD_MATCHER)) {
			const elements = document.querySelectorAll('a');
			const elementsArray = Array.from(elements);

			// Find IMDB ID
			const imdbLink = elementsArray.find((link) => link?.href?.match(IMDB_MATCHER));
			if (imdbLink) {
				const imdbId = imdbLink.href.split('/').at(4);
				if (imdbId) return { imdb: imdbId, title };
			}

			// Find TMDB ID
			const tmdbLink = elementsArray.find((link) => link?.href?.match(TMDB_MATCHER));
			if (tmdbLink) {
				const tmdbId = tmdbLink.href.split('/').at(4)?.split('-')?.at(0);
				if (tmdbId) return { tmdbId: tmdbId, title };
			}

			return null;
		}

		return null;
	}

	/**
	 * Get current URL.
	 * @returns {string} Current url without query parameters and hashes.
	 */
	function getCurrentURL() {
		return location.origin + location.pathname;
	}

	/**
	 * Extract movie title from the page
	 * @returns {string} The extracted title
	 */
	function extractTitle() {
		try {
			const titleElement = document.querySelector('meta[property="og:title"]') || document.querySelector('meta[name="twitter:title"]');
			if (!titleElement) return null;

			const title = titleElement?.content?.trim();
			if (!title) return null;

			// Skip default Kinopoisk title
			if (title.startsWith('Кинопоиск.')) return null;

			// Remove addition attachments on Kinopoisk HD
			if (title.includes('— смотреть онлайн в хорошем качестве — Кинопоиск')) {
				return title.replace('— смотреть онлайн в хорошем качестве — Кинопоиск', '').trim();
			}

			// Remove title attachment from IMDB
			if (title.includes('⭐')) {
				return title.split('⭐').at(0).trim();
			}

			// Any other IMDB attachment
			if (title.endsWith('- IMDb') && title.includes(')')) {
				const lastParenthesisIndex = title.lastIndexOf(')');
				return title.slice(0, lastParenthesisIndex + 1).trim();
			}

			return title;
		} catch (error) {
			return null;
		}
	}

	/**
	 * Add banner element to the page
	 */
	function attachBanner() {
		if (document.getElementById(BANNER_ID)) return;

		const banner = document.createElement('button');
		banner.id = BANNER_ID;
		banner.style.all = 'unset';
		banner.style.backgroundImage = `url(${BANNER_IMAGE})`;
		banner.style.backgroundSize = 'contain';
		banner.style.backgroundRepeat = 'no-repeat';
		banner.style.width = '32px';
		banner.style.height = '128px';
		banner.style.top = '-48px';
		banner.style.left = '8px';
		banner.style.opacity = '0';
		banner.style.outline = 'none';
		banner.style.cursor = 'pointer';
		banner.style.position = 'fixed';
		banner.style.zIndex = '9999999999';
		banner.style.transition = 'opacity 0.15s ease, top 0.15s ease';
		banner.style.filter = 'drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.5))';

		// Events
		banner.addEventListener('mouseover', () => (banner.style.top = '-12px'));
		banner.addEventListener('mouseout', () => (banner.style.top = '-24px'));
		banner.addEventListener('click', () => openPlayer());
		banner.addEventListener('mousedown', (event) => event.button === 1 && openPlayer(true));

		setTimeout(() => {
			banner.style.top = '-24px';
			banner.style.opacity = '1';
		}, 300);

		document.body.appendChild(banner);
	}

	/**
	 * Remove banner from the page
	 */
	function removeBanner() {
		document.getElementById(BANNER_ID)?.remove();
	}

	/**
	 * Open player with the extracted data
	 * @param {boolean} loadInBackground If true, page will be opened in background
	 */
	async function openPlayer(loadInBackground = false) {
		const data = extractMovieData();
		if (!data) return logger.error('Failed to extract movie data');

		await GM.setValue('movie-data', data);

		logger.info('Opening player for movie', data);
		GM.openInTab(PLAYER_URL, loadInBackground);
	}

	/**
	 * Init player with the extracted data.
	 * Executed on the player page only.
	 */
	async function initPlayer() {
		const data = await GM.getValue('movie-data', {});
		await GM.deleteValue('movie-data');

		// Skip initialization if no data
		if (!data || Object.keys(data).length === 0) return;

		// Stringify data twice to prevent XSS and automatically escape quotes
		const dataSerialized = JSON.stringify(JSON.stringify(data));
		const versionSerialized = JSON.stringify(VERSION);

		// Inject data to the player
		const scriptElement = document.createElement('script');
		scriptElement.innerHTML = `globalThis.init(JSON.parse(${dataSerialized}), ${versionSerialized});`;
		document.body.appendChild(scriptElement);

		logger.info('Injected movie data:', data);
	}

	// Init player or banner
	logger.info('Script executed');
	location.href.includes(PLAYER_URL) ? initPlayer() : initBanner();
})();