Greasy Fork

Greasy Fork is available in English.

Trakt.tv Universal Search (Anime and Non-Anime) - Improved v2.2

Enhanced version with better season/part matching, season subtitle support, and improved episode finding

// ==UserScript==
// @name         Trakt.tv Universal Search (Anime and Non-Anime) - Improved v2.2
// @namespace    http://tampermonkey.net/
// @version      2.2.0
// @description  Enhanced version with better season/part matching, season subtitle support, and improved episode finding
// @author       konvar
// @match        https://trakt.tv/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      hianime.to
// @connect      1flix.to
// ==/UserScript==

(function() {
    'use strict';

    // Configuration Object
    const CONFIG = {
        DEBUG: true,
        DEBOUNCE_DELAY: 300,
        TOP_RESULTS: 10,
        SIMILARITY_THRESHOLD: 0.1,
        EPISODE_TITLE_SIMILARITY_THRESHOLD: 0.8,
        MAX_SEARCH_PAGES: 1,
        CACHE_EXPIRATION_MS: 3600000, // 1 hour
        HIANIME_BASE_URL: 'https://hianime.to',
        FLIX_BASE_URL: 'https://1flix.to'
    };

    // Logging function
    function log(message) {
        if (CONFIG.DEBUG) {
            console.log(`[Trakt.tv Universal Search] ${message}`);
        }
    }

    // Debounce helper
    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');
    }

    // Optimized Levenshtein Distance implementation using a two-row approach
    function optimizedLevenshtein(a, b) {
        if (a === b) return 0;
        const alen = a.length;
        const blen = b.length;
        if (alen === 0) return blen;
        if (blen === 0) return alen;

        let prevRow = new Array(blen + 1);
        let currRow = new Array(blen + 1);

        for (let j = 0; j <= blen; j++) {
            prevRow[j] = j;
        }

        for (let i = 1; i <= alen; i++) {
            currRow[0] = i;
            for (let j = 1; j <= blen; j++) {
                const cost = a[i - 1] === b[j - 1] ? 0 : 1;
                currRow[j] = Math.min(
                    currRow[j - 1] + 1, // Insertion
                    prevRow[j] + 1, // Deletion
                    prevRow[j - 1] + cost // Substitution
                );
            }
            [prevRow, currRow] = [currRow, prevRow];
        }

        return prevRow[blen];
    }

    // Calculate similarity based on optimized Levenshtein distance
    function calculateSimilarity(a, b) {
        a = a.toLowerCase();
        b = b.toLowerCase();
        const distance = optimizedLevenshtein(a, b);
        return 1 - distance / Math.max(a.length, b.length);
    }

    // Cache for similarity computations to avoid duplicate calculations
    const similarityCache = new Map();
    function getCachedSimilarity(a, b) {
        const key = `${a}:${b}`;
        if (similarityCache.has(key)) {
            return similarityCache.get(key);
        }
        const similarity = calculateSimilarity(a, b);
        similarityCache.set(key, similarity);
        return similarity;
    }

    // Cache with expiration mechanism for HTTP requests
    const requestCache = new Map();
    function cachedRequest(url) {
        const now = Date.now();
        if (requestCache.has(url)) {
            const cached = requestCache.get(url);
            if (now - cached.timestamp < CONFIG.CACHE_EXPIRATION_MS) {
                log(`Cache hit for ${url}`);
                return cached.promise;
            } else {
                log(`Cache expired for ${url}`);
                requestCache.delete(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: promise, timestamp: now });
        return promise;
    }

    // Helper: extract movieId from URL using regex
    function extractMovieId(url) {
        // Expect URL format like "https://hianime.to/bleach-806?ref=search"
        let match = url.match(/-(\d+)(?:\?|$)/);
        if (match) {
            return match[1];
        }
        return null;
    }

    // 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, seasonSubtitle) {
            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;
            this.seasonSubtitle = seasonSubtitle;
        }

        // Extract content info from the current Trakt.tv page
        static fromDOM() {
            log('Extracting content info from DOM...');
            let titleElement = null, yearElement = null, seasonSubtitle = null;
            
            // Check for mobile title with season subtitle
            const mobileTitleElement = document.querySelector('.mobile-title h2 a:not(#level-up-link)');
            const seasonSubtitleElement = document.querySelector('.mobile-title h2 a#level-up-link');
            
            if (mobileTitleElement) {
                titleElement = mobileTitleElement;
                yearElement = document.querySelector('.mobile-title h1 .year');
                if (seasonSubtitleElement) {
                    seasonSubtitle = seasonSubtitleElement.textContent.trim();
                }
            } else 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('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(/[:.,!?]+$/, '');
                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 {
                    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(`Season Subtitle: ${seasonSubtitle}`);
                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, seasonSubtitle);
            }
            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 = `${CONFIG.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) {
                // Include season subtitle in search if available
                let searchQuery = this.contentInfo.title;
                if (this.contentInfo.seasonSubtitle) {
                    searchQuery = `${this.contentInfo.title} ${this.contentInfo.seasonSubtitle}`;
                }
                
                return this.contentInfo.contentType === 'movie' ?
                    `${CONFIG.HIANIME_BASE_URL}/search?keyword=${encodeURIComponent(searchQuery)}&type=1` :
                    `${CONFIG.HIANIME_BASE_URL}/search?keyword=${encodeURIComponent(searchQuery)}&type=2`;
            } else {
                const searchTerm = this.contentInfo.contentType === 'movie' ?
                    `${this.contentInfo.title} ${this.contentInfo.year}` :
                    this.contentInfo.title;
                return `${CONFIG.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 <= CONFIG.MAX_SEARCH_PAGES; page++) {
                const separator = this.contentInfo.isAnime ? '&' : '?';
                const pageUrl = `${searchUrl}${separator}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)}`);

            // Loop through top candidates and return the first valid content URL
            for (const match of allMatches.slice(0, CONFIG.TOP_RESULTS)) {
                try {
                    const contentUrl = await this.findContentUrl(match.url);
                    if (contentUrl) {
                        log(`Content found: ${contentUrl}`);
                        return contentUrl;
                    } else {
                        log(`No content URL found for candidate: ${match.url}`);
                    }
                } catch (error) {
                    log(`Error processing candidate ${match.url}: ${error}`);
                    continue;
                }
            }
            log(`Content not found in the top ${CONFIG.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`);
            
            // Build list of titles to check, including season subtitle combinations
            let searchTitles = [this.contentInfo.title, ...this.contentInfo.alternativeTitles];
            
            // If we have a season subtitle, add combinations
            if (this.contentInfo.seasonSubtitle) {
                searchTitles.push(`${this.contentInfo.title}: ${this.contentInfo.seasonSubtitle}`);
                searchTitles.push(`${this.contentInfo.title} ${this.contentInfo.seasonSubtitle}`);
                
                // Handle "Part 2" scenarios
                if (this.contentInfo.season && this.contentInfo.season > 1) {
                    searchTitles.push(`${this.contentInfo.title}: ${this.contentInfo.seasonSubtitle} Part 2`);
                    searchTitles.push(`${this.contentInfo.title} ${this.contentInfo.seasonSubtitle} Part 2`);
                }
            }
            
            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);
                        
                        // Calculate best score across all search titles
                        const bestScore = Math.max(...searchTitles.map(title =>
                            getCachedSimilarity(this.normalizeTitle(title), normalizedItemTitle)
                        ));
                        
                        // Bonus for exact season/part matches
                        let seasonBonus = 0;
                        if (this.contentInfo.season) {
                            const seasonRegex = new RegExp(`(season|part)\\s*${this.contentInfo.season}`, 'i');
                            if (seasonRegex.test(itemTitle)) {
                                seasonBonus = 0.2;
                            }
                            
                            // Check for "Part 2" when we're in season 4
                            if (this.contentInfo.season === 4 && /part\s*2/i.test(itemTitle)) {
                                seasonBonus = 0.3;
                            }
                        }
                        
                        const href = titleElement.getAttribute('href');
                        const url = `${this.contentInfo.isAnime ? CONFIG.HIANIME_BASE_URL : CONFIG.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 finalScore = Math.min(bestScore + seasonBonus, 1.0);
                        
                        log(`Item: "${itemTitle}", Score: ${bestScore}, Season Bonus: ${seasonBonus}, Final Score: ${finalScore}, Type: ${itemType}, Year: ${year}, Duration: ${duration}`);
                        
                        const isCorrectType = (
                            (this.contentInfo.contentType === 'movie' && itemType === 'movie') ||
                            (this.contentInfo.contentType === 'tv' && itemType === 'tv')
                        );
                        return {
                            title: itemTitle,
                            score: finalScore,
                            url: url,
                            type: itemType,
                            year: year,
                            duration: duration,
                            posterUrl: posterUrl,
                            isCorrectType: isCorrectType
                        };
                    }
                    return null;
                })
                .filter(match => match !== null && match.score >= CONFIG.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) {
                    if (this.contentInfo.contentType === 'movie') {
                        return await this.findAnimeMovieContentUrl(doc);
                    } else {
                        return await this.findAnimeSeriesContentUrl(doc, contentUrl);
                    }
                } else {
                    return await this.findNonAnimeContentUrl(doc, contentUrl);
                }
            } catch (error) {
                log(`Error fetching content: ${error}`);
                throw error;
            }
        }

        // Handle Anime Movie
        async findAnimeMovieContentUrl(doc) {
            log('Processing Anime Movie content...');
            const syncDataScript = doc.querySelector('#syncData');
            if (syncDataScript) {
                try {
                    const syncData = JSON.parse(syncDataScript.textContent);
                    if (syncData && syncData.series_url) {
                        const seriesUrl = syncData.series_url;
                        const movieId = seriesUrl.split('-').pop();
                        const watchUrl = `${CONFIG.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');
                    }
                } catch (e) {
                    log("Error parsing syncData JSON: " + e);
                }
            } else {
                log('syncData script not found on the movie page');
            }
            return null;
        }

        // Handle Anime Series (TV) - Enhanced episode finding
        async findAnimeSeriesContentUrl(doc, contentUrl) {
            log('Processing Anime Series content...');
            const movieId = extractMovieId(contentUrl);
            if (!movieId) {
                log("Could not extract movieId from contentUrl");
                return null;
            }
            const apiUrl = `${CONFIG.HIANIME_BASE_URL}/ajax/v2/episode/list/${movieId}`;
            log(`Fetching episode data from API: ${apiUrl}`);
            try {
                const episodeDataResponse = await cachedRequest(apiUrl);
                const episodeData = JSON.parse(episodeDataResponse.responseText);
                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;
                    let targetEpisodeNumber = null;
                    
                    // For series with multiple parts/seasons, calculate the actual episode number
                    if (contentUrl.includes('part-2') || contentUrl.includes('2nd-season')) {
                        // This might be a continuation, try to find the actual episode
                        targetEpisodeNumber = this.contentInfo.episode;
                    } else {
                        targetEpisodeNumber = this.contentInfo.episode;
                    }
                    
                    // First pass: look for exact episode number match
                    for (let link of episodeLinks) {
                        const episodeNumber = parseInt(link.getAttribute('data-number'));
                        const episodeTitle = link.querySelector('.ep-name')?.textContent.trim() || '';
                        log(`Episode ${episodeNumber}: "${episodeTitle}"`);
                        
                        if (episodeNumber === targetEpisodeNumber) {
                            // Found exact episode number match
                            const titleMatchScore = getCachedSimilarity(normalizedSearchTitle, this.normalizeTitle(episodeTitle));
                            log(`Episode ${episodeNumber} matches target episode number. Title match score: ${titleMatchScore}`);
                            
                            // If we have an episode title, check similarity
                            if (this.contentInfo.episodeTitle) {
                                if (titleMatchScore >= 0.3) {
                                    log(`Found matching episode by number and title`);
                                    return `${CONFIG.HIANIME_BASE_URL}${link.getAttribute('href')}`;
                                }
                            } else {
                                // No episode title to match, just use episode number
                                log(`Found matching episode by number only`);
                                return `${CONFIG.HIANIME_BASE_URL}${link.getAttribute('href')}`;
                            }
                        }
                        
                        // Track best title match regardless of episode number
                        const titleMatchScore = getCachedSimilarity(normalizedSearchTitle, this.normalizeTitle(episodeTitle));
                        if (titleMatchScore > bestMatchScore) {
                            bestMatch = link;
                            bestMatchScore = titleMatchScore;
                        }
                    }
                    
                    // Second pass: if we have a strong title match, use it
                    if (bestMatch && bestMatchScore >= CONFIG.EPISODE_TITLE_SIMILARITY_THRESHOLD) {
                        log(`Using best title match with score ${bestMatchScore}`);
                        return `${CONFIG.HIANIME_BASE_URL}${bestMatch.getAttribute('href')}`;
                    }
                    
                    // Third pass: For Part 2 series, try adjusted episode numbering
                    if (contentUrl.includes('part-2') && this.contentInfo.episode) {
                        // In Part 2, episode 14 might be episode 1
                        const adjustedEpisode = this.contentInfo.episode - 13; // Assuming Part 1 had 13 episodes
                        log(`Trying adjusted episode number for Part 2: ${adjustedEpisode}`);
                        
                        for (let link of episodeLinks) {
                            const episodeNumber = parseInt(link.getAttribute('data-number'));
                            if (episodeNumber === adjustedEpisode) {
                                log(`Found episode using adjusted numbering`);
                                return `${CONFIG.HIANIME_BASE_URL}${link.getAttribute('href')}`;
                            }
                        }
                    }
                    
                    log('No matching episode found with current criteria');
                } else {
                    log('Failed to fetch episode data from API');
                }
            } catch (error) {
                log(`Error processing anime series: ${error}`);
            }
            return null;
        }

        // Handle Non-Anime content
        async findNonAnimeContentUrl(doc, originalUrl) {
            log('Processing Non-Anime content...');
            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') {
                return await this.findNonAnimeMovieContentUrl(movieId, originalUrl);
            } else {
                return await this.findNonAnimeTvContentUrl(movieId, originalUrl);
            }
        }

        // Non-Anime Movie
        async findNonAnimeMovieContentUrl(movieId, contentUrl) {
            log('Processing Non-Anime Movie content...');
            const episodeListUrl = `${CONFIG.FLIX_BASE_URL}/ajax/episode/list/${movieId}`;
            log(`Fetching episode list from: ${episodeListUrl}`);
            try {
                const episodeListResponse = await cachedRequest(episodeListUrl);
                const episodeListContent = episodeListResponse.responseText;
                log('Episode list content fetched.');
                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');
                }
            } catch (error) {
                log(`Error processing Non-Anime Movie: ${error}`);
            }
            return null;
        }

        // Non-Anime TV
        async findNonAnimeTvContentUrl(movieId, contentUrl) {
            log('Processing Non-Anime TV content...');
            const seasonListUrl = `${CONFIG.FLIX_BASE_URL}/ajax/season/list/${movieId}`;
            log(`Fetching season list from: ${seasonListUrl}`);
            try {
                const seasonListResponse = await cachedRequest(seasonListUrl);
                const seasonListContent = seasonListResponse.responseText;
                log('Season list content fetched.');
                const seasonListDoc = parseHTML(seasonListContent);
                const seasonItems = seasonListDoc.querySelectorAll('.ss-item');
                for (let seasonItem of seasonItems) {
                    const seasonNumberText = seasonItem.textContent.trim();
                    const seasonNumber = parseInt(seasonNumberText.split(' ')[1]);
                    const seasonId = seasonItem.getAttribute('data-id');
                    log(`Checking Season ${seasonNumber}`);
                    if (seasonNumber === this.contentInfo.season) {
                        const episodeListUrl = `${CONFIG.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 fetched.');
                        const episodeListDoc = parseHTML(episodeListContent);
                        const episodeItems = episodeListDoc.querySelectorAll('.eps-item');
                        for (let episodeItem of episodeItems) {
                            const titleAttr = episodeItem.getAttribute('title');
                            if (!titleAttr) continue;
                            const parts = titleAttr.split(':');
                            if (parts.length < 2) continue;
                            const episodeNumber = parseInt(parts[0].replace('Eps', '').trim());
                            const episodeTitle = parts[1].trim();
                            log(`Checking Season ${seasonNumber}, Episode ${episodeNumber}: "${episodeTitle}"`);
                            if (episodeNumber === this.contentInfo.episode) {
                                const episodeId = episodeItem.getAttribute('data-id');
                                const serverListUrl = `${CONFIG.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 fetched.');
                                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');
                                }
                            }
                        }
                    }
                }
            } catch (error) {
                log(`Error processing Non-Anime TV: ${error}`);
            }
            log('No matching episode found');
            return null;
        }

        normalizeTitle(title) {
            return title.toLowerCase()
                .replace(/[:.,!?'`]+/g, '')
                .replace(/\s+/g, ' ')
                .replace(/[^\w\s]/g, '')
                .trim();
        }

        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;
            this.observer = null;
        }

        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");
                        }
                    }
                    if (this.observer) {
                        this.observer.disconnect();
                        log('DOM observer disconnected.');
                    }
                } 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();
            }, CONFIG.DEBOUNCE_DELAY);
            this.observer = new MutationObserver(() => {
                if (!this.isInitialized) {
                    debouncedInit();
                }
            });
            this.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.");
        }
    }
})();