Trakt.tv Universal Search (Anime and Non-Anime)

Search for anime on hianime.to and non-anime content on 1flix.to from Trakt.tv

// ==UserScript==
// @name         Trakt.tv Universal Search (Anime and Non-Anime)
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Search for anime on hianime.to and non-anime content on 1flix.to from Trakt.tv
// @author       konvar
// @match        https://trakt.tv/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      hianime.to
// @connect      1flix.to
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    const HIANIME_BASE_URL = 'https://hianime.to';
    const FLIX_BASE_URL = 'https://1flix.to';
    const TOP_RESULTS = 10;
    const SIMILARITY_THRESHOLD = 0.4;
    const EPISODE_TITLE_SIMILARITY_THRESHOLD = 0.8;
    const MAX_SEARCH_PAGES = 1;

    GM_addStyle(`
        .trakt-universal-search-button {
            display: flex;
            align-items: center;
            justify-content: center;
            margin-bottom: 10px;
            /* Remove default button styles */
            background: none;
            border: none;
            padding: 0;
            cursor: pointer; /* Maintain pointer cursor */
        }
        .trakt-universal-search-button:hover {
            /* Prevent white glow on hover */
            box-shadow: none;
        }
        .trakt-universal-search-button img {
            max-height: 30px;
            width: auto;
        }
    `);

    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;
        }

        static fromDOM() {
            let titleElement, yearElement;
            if (window.location.pathname.startsWith('/movies/')) {
                const movieTitleElement = document.querySelector('h1');
                if (movieTitleElement) {
                    titleElement = movieTitleElement.childNodes[0];
                    yearElement = movieTitleElement.querySelector('.year');
                }
            } else {
                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');
            const genreElements = document.querySelectorAll('.genres .btn');
            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(/[:.,!?]+$/, '');
                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;
                if (yearElement) {
                    year = yearElement.textContent.trim();
                } 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';

                return new ContentInfo(title, year, isAnime, season, episode, episodeTitle, alternativeTitles, contentType, absoluteEpisode);
            }
            return null;
        }
    }

    class SearchButton {
        constructor(contentInfo) {
            this.contentInfo = contentInfo;
            this.button = this.createButton();
        }

        createButton() {
            const button = document.createElement('button'); // Changed from <a> to <button>
            button.className = 'btn btn-block btn-summary trakt-universal-search-button'; // Updated class name
            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() {
            const container = document.querySelector('.col-lg-4.col-md-5.action-buttons');
            if (container && !document.querySelector('.trakt-universal-search-button')) { // Updated class name
                container.insertBefore(this.button, container.firstChild);
                return true;
            }
            return false;
        }

        updateWithContentLink(url) {
            this.button.addEventListener('click', () => {
                window.open(url, '_blank');
            });
            this.button.style.display = 'flex';
        }


        updateButtonText(text) {
            const textNode = document.createTextNode(` ${text}`);
            this.button.appendChild(textNode);
        }
    }

    class ContentSearcher {
        constructor(contentInfo) {
            this.contentInfo = contentInfo;
        }

        generateSearchUrl() {
            if (this.contentInfo.isAnime) {
                if (this.contentInfo.contentType === 'movie') {
                    return `${HIANIME_BASE_URL}/search?keyword=${encodeURIComponent(this.contentInfo.title)}&type=1`;
                } else {
                    return `${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() {
            let page = 1;
            let allMatches = [];
            const searchUrl = this.generateSearchUrl();

            while (page <= MAX_SEARCH_PAGES) {
                const pageUrl = `${searchUrl}${this.contentInfo.isAnime ? '&' : '?'}page=${page}`;
                try {
                    const response = await this.makeRequest(pageUrl);
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(response.responseText, 'text/html');
                    const pageMatches = this.findTopMatches(doc);

                    allMatches = allMatches.concat(pageMatches);

                    if (pageMatches.length === 0) break;
                    page++;
                } catch (error) {
                    break;
                }
            }

            for (const match of allMatches.slice(0, TOP_RESULTS)) {
                const contentUrl = await this.findContentUrl(match.url);
                if (contentUrl) {
                    return contentUrl;
                }
            }

            this.showMessage(`Content not found. Click the button to search manually.`);
            return searchUrl;
        }

        findTopMatches(doc) {
            const contentItems = doc.querySelectorAll('.flw-item');

            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;

                    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);

            return matches;
        }

        async findContentUrl(contentUrl) {
            try {
                const response = await this.makeRequest(contentUrl);
                const parser = new DOMParser();
                const doc = parser.parseFromString(response.responseText, 'text/html');

                if (this.contentInfo.isAnime && this.contentInfo.contentType === 'movie') {
                    const syncDataScript = doc.querySelector('#syncData');
                    if (syncDataScript) {
                        const syncData = JSON.parse(syncDataScript.textContent);
                        const seriesUrl = syncData.series_url;
                        if (seriesUrl) {
                            const movieId = seriesUrl.split('-').pop();
                            const watchUrl = `${HIANIME_BASE_URL}/watch/${seriesUrl.slice(seriesUrl.lastIndexOf('/') + 1)}?ep=${movieId}`;
                            return watchUrl;
                        }
                    }
                } else if (this.contentInfo.isAnime) {
                    const movieId = contentUrl.split('-').pop();
                    const apiUrl = `${HIANIME_BASE_URL}/ajax/v2/episode/list/${movieId}`;

                    const episodeDataResponse = await this.makeRequest(apiUrl);
                    const episodeData = JSON.parse(episodeDataResponse.responseText);


                    if (episodeData.status && episodeData.html) {
                        const episodeDoc = parser.parseFromString(episodeData.html, 'text/html');
                        const episodeLinks = episodeDoc.querySelectorAll('.ssl-item.ep-item');

                        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() || '';

                            const normalizedEpisodeTitle = this.normalizeTitle(episodeTitle);
                            const titleMatchScore = this.calculateMatchScore(normalizedSearchTitle, normalizedEpisodeTitle);

                            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 {
                    const detailPageWatch = doc.querySelector('.detail_page-watch');
                    if (!detailPageWatch) {
                        return null;
                    }

                    const movieId = detailPageWatch.getAttribute('data-id');
                    const movieType = detailPageWatch.getAttribute('data-type');

                    if (!movieId || !movieType) {
                        return null;
                    }

                    if (this.contentInfo.contentType === 'movie') {
                        const episodeListUrl = `${FLIX_BASE_URL}/ajax/episode/list/${movieId}`;
                        const episodeListResponse = await this.makeRequest(episodeListUrl);
                        const episodeListContent = episodeListResponse.responseText;

                        const episodeListDoc = parser.parseFromString(episodeListContent, 'text/html');
                        const serverItem = episodeListDoc.querySelector('.link-item');

                        if (serverItem) {
                            const serverId = serverItem.getAttribute('data-linkid');
                            const watchUrl = contentUrl.replace(/\/movie\//, '/watch-movie/') + `.${serverId}`;
                            return watchUrl;
                        }
                    } else {
                        const seasonListUrl = `${FLIX_BASE_URL}/ajax/season/list/${movieId}`;
                        const seasonListResponse = await this.makeRequest(seasonListUrl);
                        const seasonListContent = seasonListResponse.responseText;

                        const seasonListDoc = parser.parseFromString(seasonListContent, 'text/html');
                        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');

                            if (seasonNumber === this.contentInfo.season) {
                                const episodeListUrl = `${FLIX_BASE_URL}/ajax/season/episodes/${seasonId}`;
                                const episodeListResponse = await this.makeRequest(episodeListUrl);
                                const episodeListContent = episodeListResponse.responseText;

                                const episodeListDoc = parser.parseFromString(episodeListContent, 'text/html');
                                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();

                                    if (episodeNumber === this.contentInfo.episode) {
                                        const episodeId = episodeItem.getAttribute('data-id');

                                        const serverListUrl = `${FLIX_BASE_URL}/ajax/episode/servers/${episodeId}`;
                                        const serverListResponse = await this.makeRequest(serverListUrl);
                                        const serverListContent = serverListResponse.responseText;

                                        const serverListDoc = parser.parseFromString(serverListContent, 'text/html');
                                        const serverItem = serverListDoc.querySelector('.link-item');

                                        if (serverItem) {
                                            const serverId = serverItem.getAttribute('data-id');
                                            const watchUrl = contentUrl.replace(/\/tv\//, '/watch-tv/') + `.${serverId}`;
                                            return watchUrl;
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            } catch (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);
        }

        makeRequest(url) {
            return 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);
                    }
                });
            });
        }

        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 TraktTvHandler {
        constructor() {
            this.isInitialized = false;
        }

        async init() {
            if (this.isInitialized) {
                return;
            }

            const contentInfo = ContentInfo.fromDOM();
            if (contentInfo) {
                const searchButton = new SearchButton(contentInfo);
                if (searchButton.addToDOM()) {
                    this.isInitialized = true;

                    const contentSearcher = new ContentSearcher(contentInfo);
                    const result = await contentSearcher.search();
                    if (result) {
                        searchButton.updateWithContentLink(result);
                        if (result === contentSearcher.generateSearchUrl()) {
                            searchButton.updateButtonText("Search Manually");
                        }
                    }
                } else {
                    setTimeout(() => this.init(), 1000);
                }
            } else {
                setTimeout(() => this.init(), 1000);
            }
        }

        setupObserver() {
            const observer = new MutationObserver((mutations) => {
                if (!this.isInitialized) {
                    for (let mutation of mutations) {
                        if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                            this.init();
                            break;
                        }
                    }
                }
            });
            observer.observe(document.body, { childList: true, subtree: true });
        }
    }

    if (window.location.hostname === 'trakt.tv') {
        if (window.location.pathname.startsWith('/shows/') || window.location.pathname.startsWith('/movies/')) {
            setTimeout(() => {
                const traktHandler = new TraktTvHandler();
                traktHandler.init();
                traktHandler.setupObserver();
            }, 1000);
        }
    }
})();