// ==UserScript== // @name Trakt.tv Universal Search (Anime and Non-Anime) // @namespace http://tampermonkey.net/ // @version 1.4 // @description Search for anime on hianime.to and non-anime content on 1flix.to from Trakt.tv with improved performance, reliability, caching, and concurrent requests // @author konvar // @license MIT // @match https://trakt.tv/* // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect hianime.to // @connect 1flix.to // @downloadURL https://update.greasyfork.icu/scripts/508020/Trakttv%20Universal%20Search%20%28Anime%20and%20Non-Anime%29.user.js // @updateURL https://update.greasyfork.icu/scripts/508020/Trakttv%20Universal%20Search%20%28Anime%20and%20Non-Anime%29.meta.js // ==/UserScript== (function() { 'use strict'; // Toggle debug logging (set to false in production) const DEBUG = true; function log(message) { if (DEBUG) { console.log(`[Trakt.tv Universal Search] ${message}`); } } // Helper: debounce to limit rapid calls function debounce(fn, delay) { let timer = null; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); } } // Helper: parse HTML string into a document function parseHTML(htmlString) { return new DOMParser().parseFromString(htmlString, 'text/html'); } // Global cache for network requests const requestCache = new Map(); function cachedRequest(url) { if (requestCache.has(url)) { return requestCache.get(url); } const promise = new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' }, onload: function(response) { if (response.status === 200) { resolve(response); } else { reject(new Error(`Failed to fetch content: ${response.status}`)); } }, onerror: function(error) { reject(error); } }); }); requestCache.set(url, promise); return promise; } const HIANIME_BASE_URL = 'https://hianime.to'; const FLIX_BASE_URL = 'https://1flix.to'; const TOP_RESULTS = 10; // Top search results to consider const SIMILARITY_THRESHOLD = 0.4; // Minimum similarity score for a match const EPISODE_TITLE_SIMILARITY_THRESHOLD = 0.8; // Minimum similarity for episode titles const MAX_SEARCH_PAGES = 1; // Max number of search pages // Add custom CSS for the search button GM_addStyle(` .trakt-universal-search-button { display: flex; align-items: center; justify-content: center; margin-bottom: 10px; background: none; border: none; padding: 0; cursor: pointer; } .trakt-universal-search-button:hover { box-shadow: none; } .trakt-universal-search-button img { max-height: 30px; width: auto; } `); // Class to store content details extracted from the DOM class ContentInfo { constructor(title, year, isAnime, season, episode, episodeTitle, alternativeTitles, contentType, absoluteEpisode) { this.title = title; this.year = year; this.isAnime = isAnime; this.season = season; this.episode = episode; this.episodeTitle = episodeTitle; this.alternativeTitles = alternativeTitles; this.contentType = contentType; this.absoluteEpisode = absoluteEpisode; } // Extract content info from the current Trakt.tv page static fromDOM() { log('Extracting content info from DOM...'); let titleElement = null, yearElement = null; // Try mobile layout first (using .mobile-title) const mobileTitleElement = document.querySelector('.mobile-title h2 a:not(#level-up-link)'); if (mobileTitleElement) { titleElement = mobileTitleElement; yearElement = document.querySelector('.mobile-title h1 .year'); } else if (window.location.pathname.startsWith('/movies/')) { // Desktop movie page const movieTitleElement = document.querySelector('h1'); if (movieTitleElement) { titleElement = movieTitleElement.childNodes[0]; yearElement = movieTitleElement.querySelector('.year'); } } else { // Desktop TV show page titleElement = document.querySelector('h2 a[data-safe="true"]'); } const episodeElement = document.querySelector('h1.episode .main-title-sxe'); const episodeTitleElement = document.querySelector('h1.episode .main-title'); const episodeAbsElement = document.querySelector('h1.episode .main-title-abs'); // Use genre spans to help detect if the content is anime const genreElements = document.querySelectorAll('span[itemprop="genre"]'); const additionalStats = document.querySelector('ul.additional-stats'); const alternativeTitleElement = document.querySelector('.additional-stats .meta-data[data-type="alternative_titles"]'); if (titleElement) { const title = titleElement.textContent.trim().replace(/[:.,!?]+$/, ''); // Expect episode info like "2x09" const episodeInfo = episodeElement ? episodeElement.textContent.trim().split('x') : null; const season = episodeInfo ? parseInt(episodeInfo[0]) : null; const episode = episodeInfo ? parseInt(episodeInfo[1]) : null; const episodeTitle = episodeTitleElement ? episodeTitleElement.textContent.trim() : null; const absoluteEpisode = episodeAbsElement ? parseInt(episodeAbsElement.textContent.trim().replace(/[\(\)]/g, '')) : null; const genres = Array.from(genreElements).map(el => el.textContent.trim().toLowerCase()); const isAnime = genres.includes('anime') || (additionalStats && additionalStats.textContent.toLowerCase().includes('anime')) || document.querySelector('.poster img[src*="anime"]') !== null; let year = null; if (yearElement && yearElement.textContent.trim() !== "") { year = yearElement.textContent.trim(); } else { // Fallback: try the hidden meta field const metaFirstAired = document.querySelector('#meta-first-aired'); if (metaFirstAired) { const date = new Date(metaFirstAired.value); if (!isNaN(date)) { year = date.getFullYear().toString(); } } else if (additionalStats) { const yearMatch = additionalStats.textContent.match(/(\d{4})/); year = yearMatch ? yearMatch[1] : null; } } const alternativeTitles = alternativeTitleElement ? alternativeTitleElement.textContent.split(',').map(t => t.trim()) : []; const contentType = window.location.pathname.startsWith('/movies/') ? 'movie' : 'tv'; log(`Title: ${title}`); log(`Year: ${year}`); log(`Is Anime: ${isAnime}`); log(`Season: ${season}`); log(`Episode: ${episode}`); log(`Episode Title: ${episodeTitle}`); log(`Alternative Titles: ${alternativeTitles}`); log(`Content Type: ${contentType}`); log(`Absolute Episode: ${absoluteEpisode}`); return new ContentInfo(title, year, isAnime, season, episode, episodeTitle, alternativeTitles, contentType, absoluteEpisode); } log('Failed to extract content info.'); return null; } } // Class to create and manage the search button class SearchButton { constructor(contentInfo) { this.contentInfo = contentInfo; this.button = this.createButton(); } createButton() { log('Creating search button...'); const button = document.createElement('button'); button.className = 'btn btn-block btn-summary trakt-universal-search-button'; button.style.display = 'none'; const icon = document.createElement('img'); icon.style.width = 'auto'; icon.style.height = '50px'; if (this.contentInfo.isAnime) { icon.src = `${HIANIME_BASE_URL}/images/logo.png`; icon.alt = 'Hianime Logo'; } else { icon.src = 'https://img.1flix.to/xxrz/400x400/100/e4/ca/e4ca1fc10cda9cf762f7b51876dc917b/e4ca1fc10cda9cf762f7b51876dc917b.png'; icon.alt = '1flix Logo'; } button.appendChild(icon); return button; } addToDOM() { log('Adding search button to DOM...'); const container = document.querySelector('.col-lg-4.col-md-5.action-buttons'); if (container && !document.querySelector('.trakt-universal-search-button')) { container.insertBefore(this.button, container.firstChild); log('Search button added to DOM.'); return true; } log('Failed to add search button to DOM.'); return false; } updateWithContentLink(url) { log('Updating search button with content link...'); this.button.addEventListener('click', () => window.open(url, '_blank')); this.button.style.display = 'flex'; log('Search button updated and displayed.'); } updateButtonText(text) { log('Updating search button text...'); const textNode = document.createTextNode(` ${text}`); this.button.appendChild(textNode); log('Search button text updated.'); } } // Class to search external sites for content and find the correct URL class ContentSearcher { constructor(contentInfo) { this.contentInfo = contentInfo; } generateSearchUrl() { log('Generating search URL...'); if (this.contentInfo.isAnime) { return this.contentInfo.contentType === 'movie' ? `${HIANIME_BASE_URL}/search?keyword=${encodeURIComponent(this.contentInfo.title)}&type=1` : `${HIANIME_BASE_URL}/search?keyword=${encodeURIComponent(this.contentInfo.title)}&type=2`; } else { const searchTerm = this.contentInfo.contentType === 'movie' ? `${this.contentInfo.title} ${this.contentInfo.year}` : this.contentInfo.title; return `${FLIX_BASE_URL}/search/${searchTerm.replace(/\s+/g, '-')}`; } } async search() { log('Searching for content concurrently...'); const searchUrl = this.generateSearchUrl(); const pageRequests = []; for (let page = 1; page <= MAX_SEARCH_PAGES; page++) { const pageUrl = `${searchUrl}${this.contentInfo.isAnime ? '&' : '?'}page=${page}`; log(`Queuing search URL: ${pageUrl}`); pageRequests.push(cachedRequest(pageUrl)); } let allMatches = []; try { const responses = await Promise.all(pageRequests); responses.forEach((response, index) => { const doc = parseHTML(response.responseText); const pageMatches = this.findTopMatches(doc); log(`Matches on page ${index + 1}: ${JSON.stringify(pageMatches)}`); allMatches = allMatches.concat(pageMatches); }); } catch (error) { log(`Error during concurrent page requests: ${error}`); } log(`All matches: ${JSON.stringify(allMatches)}`); for (const match of allMatches.slice(0, TOP_RESULTS)) { const contentUrl = await this.findContentUrl(match.url); if (contentUrl) { log(`Content found: ${contentUrl}`); return contentUrl; } } log(`Content not found in the top ${TOP_RESULTS} results`); this.showMessage(`Content not found. Click the button to search manually.`); return searchUrl; } findTopMatches(doc) { log('Finding top matches on search results page...'); const contentItems = doc.querySelectorAll('.flw-item'); log(`Found ${contentItems.length} items in search results`); const allTitles = [this.contentInfo.title, ...this.contentInfo.alternativeTitles]; const matches = Array.from(contentItems) .map(item => { const titleElement = item.querySelector('.film-name a'); const posterElement = item.querySelector('.film-poster-img'); const infoElement = item.querySelector('.fd-infor'); if (titleElement && infoElement) { const itemTitle = titleElement.textContent.trim(); const normalizedItemTitle = this.normalizeTitle(itemTitle); const bestScore = Math.max(...allTitles.map(title => this.calculateMatchScore(this.normalizeTitle(title), normalizedItemTitle) )); const href = titleElement.getAttribute('href'); const url = `${this.contentInfo.isAnime ? HIANIME_BASE_URL : FLIX_BASE_URL}${href}`; let itemType, year, duration; const itemTypeElement = infoElement.querySelector('.fdi-item'); const itemTypeText = itemTypeElement ? itemTypeElement.textContent.trim().toLowerCase() : ''; const seasonMatch = itemTypeText.match(/^ss (\d+)$/); if (seasonMatch) { itemType = 'tv'; } else { const yearRegex = /^\d{4}$/; if (yearRegex.test(itemTypeText)) { year = itemTypeText; itemType = 'movie'; } else { itemType = itemTypeText; year = null; } } const durationElement = infoElement.querySelector('.fdi-duration'); duration = durationElement ? durationElement.textContent.trim() : null; const posterUrl = posterElement ? posterElement.getAttribute('data-src') : null; log(`Item: "${itemTitle}", Score: ${bestScore}, Type: ${itemType}, Year: ${year}, Duration: ${duration}`); const isCorrectType = ( (this.contentInfo.contentType === 'movie' && itemType === 'movie') || (this.contentInfo.contentType === 'tv' && itemType === 'tv') ); return { title: itemTitle, score: bestScore, url: url, type: itemType, year: year, duration: duration, posterUrl: posterUrl, isCorrectType: isCorrectType }; } return null; }) .filter(match => match !== null && match.score >= SIMILARITY_THRESHOLD && match.isCorrectType) .sort((a, b) => b.score - a.score); log(`Filtered matches: ${JSON.stringify(matches)}`); return matches; } async findContentUrl(contentUrl) { log(`Fetching content from URL: ${contentUrl}`); try { const response = await cachedRequest(contentUrl); const doc = parseHTML(response.responseText); if (this.contentInfo.isAnime && this.contentInfo.contentType === 'movie') { const syncDataScript = doc.querySelector('#syncData'); if (syncDataScript) { let syncData; try { syncData = JSON.parse(syncDataScript.textContent); } catch (e) { log("Error parsing syncData JSON: " + e); } if (syncData && syncData.series_url) { const seriesUrl = syncData.series_url; const movieId = seriesUrl.split('-').pop(); const watchUrl = `${HIANIME_BASE_URL}/watch/${seriesUrl.slice(seriesUrl.lastIndexOf('/') + 1)}?ep=${movieId}`; log(`Match found: ${watchUrl}`); return watchUrl; } else { log('Series URL not found in syncData'); } } else { log('syncData script not found on the movie page'); } } else if (this.contentInfo.isAnime) { const movieId = contentUrl.split('-').pop(); const apiUrl = `${HIANIME_BASE_URL}/ajax/v2/episode/list/${movieId}`; log(`Fetching episode data from API: ${apiUrl}`); const episodeDataResponse = await cachedRequest(apiUrl); let episodeData; try { episodeData = JSON.parse(episodeDataResponse.responseText); } catch (e) { log("Error parsing episode data JSON: " + e); } log('Episode data fetched:'); log(`Total episodes: ${episodeData ? episodeData.totalItems : 'unknown'}`); if (episodeData && episodeData.status && episodeData.html) { const episodeDoc = parseHTML(episodeData.html); const episodeLinks = episodeDoc.querySelectorAll('.ssl-item.ep-item'); log(`Number of episode links found: ${episodeLinks.length}`); const normalizedSearchTitle = this.normalizeTitle(this.contentInfo.episodeTitle); let bestMatch = null; let bestMatchScore = 0; for (let i = 0; i < episodeLinks.length; i++) { const link = episodeLinks[i]; const episodeNumber = parseInt(link.getAttribute('data-number')); const episodeTitle = link.querySelector('.ep-name')?.textContent.trim() || ''; log(`Episode ${episodeNumber}: "${episodeTitle}"`); const normalizedEpisodeTitle = this.normalizeTitle(episodeTitle); const titleMatchScore = this.calculateMatchScore(normalizedSearchTitle, normalizedEpisodeTitle); log(`Match score for "${episodeTitle}": ${titleMatchScore}`); if (episodeNumber === this.contentInfo.episode || episodeNumber === this.contentInfo.absoluteEpisode) { if (titleMatchScore >= 0.3) { return `${HIANIME_BASE_URL}${link.getAttribute('href')}`; } } if (titleMatchScore >= EPISODE_TITLE_SIMILARITY_THRESHOLD && (episodeNumber === this.contentInfo.episode || episodeNumber === this.contentInfo.absoluteEpisode)) { return `${HIANIME_BASE_URL}${link.getAttribute('href')}`; } if (titleMatchScore > bestMatchScore) { bestMatch = link; bestMatchScore = titleMatchScore; } } if (bestMatch && bestMatchScore >= EPISODE_TITLE_SIMILARITY_THRESHOLD) { return `${HIANIME_BASE_URL}${bestMatch.getAttribute('href')}`; } } else { log('Failed to fetch episode data from API'); } } else { const detailPageWatch = doc.querySelector('.detail_page-watch'); if (!detailPageWatch) { log('Detail page watch element not found'); return null; } const movieId = detailPageWatch.getAttribute('data-id'); const movieType = detailPageWatch.getAttribute('data-type'); if (!movieId || !movieType) { log('Movie ID or type not found'); return null; } log(`Movie ID: ${movieId}, Movie Type: ${movieType}`); if (this.contentInfo.contentType === 'movie') { const episodeListUrl = `${FLIX_BASE_URL}/ajax/episode/list/${movieId}`; log(`Fetching episode list from: ${episodeListUrl}`); const episodeListResponse = await cachedRequest(episodeListUrl); const episodeListContent = episodeListResponse.responseText; log('Episode list content:'); log(episodeListContent); const episodeListDoc = parseHTML(episodeListContent); const serverItem = episodeListDoc.querySelector('.link-item'); if (serverItem) { const serverId = serverItem.getAttribute('data-linkid'); const watchUrl = contentUrl.replace(/\/movie\//, '/watch-movie/') + `.${serverId}`; log(`Match found: ${watchUrl}`); return watchUrl; } else { log('No server found for this movie'); } } else { const seasonListUrl = `${FLIX_BASE_URL}/ajax/season/list/${movieId}`; log(`Fetching season list from: ${seasonListUrl}`); const seasonListResponse = await cachedRequest(seasonListUrl); const seasonListContent = seasonListResponse.responseText; log('Season list content:'); log(seasonListContent); const seasonListDoc = parseHTML(seasonListContent); const seasonItems = seasonListDoc.querySelectorAll('.ss-item'); for (let seasonItem of seasonItems) { const seasonNumber = parseInt(seasonItem.textContent.trim().split(' ')[1]); const seasonId = seasonItem.getAttribute('data-id'); log(`Checking Season ${seasonNumber}`); if (seasonNumber === this.contentInfo.season) { const episodeListUrl = `${FLIX_BASE_URL}/ajax/season/episodes/${seasonId}`; log(`Fetching episode list from: ${episodeListUrl}`); const episodeListResponse = await cachedRequest(episodeListUrl); const episodeListContent = episodeListResponse.responseText; log('Episode list content:'); log(episodeListContent); const episodeListDoc = parseHTML(episodeListContent); const episodeItems = episodeListDoc.querySelectorAll('.eps-item'); for (let episodeItem of episodeItems) { const episodeNumber = parseInt(episodeItem.getAttribute('title').split(':')[0].replace('Eps', '').trim()); const episodeTitle = episodeItem.getAttribute('title').split(':')[1].trim(); log(`Checking Season ${seasonNumber}, Episode ${episodeNumber}: "${episodeTitle}"`); if (episodeNumber === this.contentInfo.episode) { const episodeId = episodeItem.getAttribute('data-id'); const serverListUrl = `${FLIX_BASE_URL}/ajax/episode/servers/${episodeId}`; log(`Fetching server list from: ${serverListUrl}`); const serverListResponse = await cachedRequest(serverListUrl); const serverListContent = serverListResponse.responseText; log('Server list content:'); log(serverListContent); const serverListDoc = parseHTML(serverListContent); const serverItem = serverListDoc.querySelector('.link-item'); if (serverItem) { const serverId = serverItem.getAttribute('data-id'); const watchUrl = contentUrl.replace(/\/tv\//, '/watch-tv/') + `.${serverId}`; log(`Match found: ${watchUrl}`); return watchUrl; } else { log('No server found for this episode'); } } } } } } log('No matching episode found'); } } catch (error) { log(`Error fetching content: ${error}`); } return null; } normalizeTitle(title) { return title.toLowerCase() .replace(/[:.,!?'`]+/g, '') .replace(/\s+/g, ' ') .replace(/[^\w\s]/g, '') .trim(); } calculateMatchScore(searchTitle, itemTitle) { const words1 = searchTitle.split(' '); const words2 = itemTitle.split(' '); const commonWords = words1.filter(word => words2.includes(word)); return commonWords.length / Math.max(words1.length, words2.length); } showMessage(message) { const messageDiv = document.createElement('div'); messageDiv.textContent = message; messageDiv.style.cssText = "position: fixed; top: 10px; left: 50%; transform: translateX(-50%); background-color: #f8d7da; color: #721c24; padding: 10px; border-radius: 5px; z-index: 9999;"; document.body.appendChild(messageDiv); setTimeout(() => messageDiv.remove(), 5000); } } // Class to manage initialization on Trakt.tv pages class TraktTvHandler { constructor() { this.isInitialized = false; } async init() { log('Initializing script...'); if (this.isInitialized) { log("Script already initialized, skipping..."); return; } const contentInfo = ContentInfo.fromDOM(); if (contentInfo) { const searchButton = new SearchButton(contentInfo); if (searchButton.addToDOM()) { this.isInitialized = true; log("Script initialization complete."); const contentSearcher = new ContentSearcher(contentInfo); const result = await contentSearcher.search(); if (result) { searchButton.updateWithContentLink(result); if (result === contentSearcher.generateSearchUrl()) { searchButton.updateButtonText("Search Manually"); } } } else { log("Failed to add search button to DOM. Retrying in 1 second..."); setTimeout(() => this.init(), 1000); } } else { log("Content info not found. Retrying in 1 second..."); setTimeout(() => this.init(), 1000); } } setupObserver() { log('Setting up DOM observer...'); const debouncedInit = debounce(() => { log("DOM mutation detected, attempting to initialize..."); this.init(); }, 300); const observer = new MutationObserver((mutations) => { if (!this.isInitialized) { debouncedInit(); } }); observer.observe(document.body, { childList: true, subtree: true }); log('DOM observer set up.'); } } // Initialize only on Trakt.tv show or movie pages if (window.location.hostname === 'trakt.tv') { if (window.location.pathname.startsWith('/shows/') || window.location.pathname.startsWith('/movies/')) { log('Running on a Trakt.tv show or movie page.'); setTimeout(() => { const traktHandler = new TraktTvHandler(); traktHandler.init(); traktHandler.setupObserver(); log("Script setup complete on Trakt.tv"); }, 1000); } else { log("Not on a show or movie page, script not initialized."); } } })();