From 15b3bcdee2ed7abbf94c8974ef0871036b372ade Mon Sep 17 00:00:00 2001 From: tomasalias Date: Mon, 26 May 2025 16:12:54 +0200 Subject: [PATCH 1/2] initial version --- README.md | 14 +- addon.js | 213 ++++++++++++++++++++++++---- package-lock.json | 351 +++------------------------------------------- package.json | 14 +- server.js | 12 ++ setup-tmdb.js | 66 +++++++++ test-env.js | 28 ++++ 7 files changed, 328 insertions(+), 370 deletions(-) create mode 100644 setup-tmdb.js create mode 100644 test-env.js diff --git a/README.md b/README.md index 0d1ced8..5066d4c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Stremio Hellspy.to Addon -This addon allows you to search and stream content from Hellspy.to directly in Stremio. +This addon allows you to search and stream content from Hellspy.to directly in Stremio, with enhanced metadata from TMDb (The Movie Database). ## Features - Search for movies and series on Hellspy.to +- Enhanced metadata using TMDb API (when API key is provided) +- Fallback to Wikidata for metadata when TMDb API key is not available - Stream content directly in Stremio (requires stremio-local-addon) - No login required @@ -23,7 +25,15 @@ This addon allows you to search and stream content from Hellspy.to directly in S ``` npm install ``` -5. Start the addon: +5. (Optional) Set up TMDb API: + - Get your [TMDb API key](https://www.themoviedb.org/settings/api) + - Set the environment variable: + ``` + set TMDB_API_KEY=your_api_key_here # On Windows + export TMDB_API_KEY=your_api_key_here # On Linux/macOS + ``` + +6. Start the addon: ``` npm start ``` diff --git a/addon.js b/addon.js index a8d6dae..824e423 100644 --- a/addon.js +++ b/addon.js @@ -1,12 +1,25 @@ +// Load environment variables from .env file +require("dotenv").config(); + const { addonBuilder } = require("stremio-addon-sdk"); const axios = require("axios"); -// Create a new addon builder instance +// TMDb API configuration +const TMDB_API_KEY = process.env.TMDB_API_KEY; +const LANGUAGE = "cs-CZ"; // Czech language +const TMDB_BASE = "https://api.themoviedb.org/3"; +const USE_TMDB = !!TMDB_API_KEY; + +console.log(`TMDb API Key: ${TMDB_API_KEY ? "Found" : "Not found"}`); +console.log(`TMDb API is ${USE_TMDB ? "ENABLED" : "DISABLED"}`); + const builder = new addonBuilder({ id: "org.stremio.hellspy", - version: "0.0.1", - name: "Hellspy", - description: "Hellspy.to addon for Stremio", + version: "0.1.0", + name: USE_TMDB ? "Hellspy with TMDb" : "Hellspy", + description: USE_TMDB + ? "Hellspy.to addon for Stremio with enhanced TMDb metadata" + : "Hellspy.to addon for Stremio (TMDb metadata disabled)", resources: ["stream"], types: ["movie", "series"], idPrefixes: ["tt", "kitsu"], @@ -59,7 +72,6 @@ async function getStreamUrl(id, fileHash) { `https://api.hellspy.to/gw/video/${id}/${fileHash}` ); - // Get video details for additional information const title = response.data.title || ""; const duration = response.data.duration || 0; console.log( @@ -68,10 +80,8 @@ async function getStreamUrl(id, fileHash) { }s)` ); - // Extract available stream qualities const conversions = response.data.conversions || {}; - // If no conversions are available, try the direct download link as a fallback if (Object.keys(conversions).length === 0 && response.data.download) { console.log( "No conversions available, using direct download link as fallback" @@ -87,7 +97,6 @@ async function getStreamUrl(id, fileHash) { return streams; } - // Map conversions to stream format const streams = Object.entries(conversions).map(([quality, url]) => ({ url, quality: quality + "p", @@ -114,7 +123,6 @@ async function getTitleFromWikidata(imdbId) { `Fetching Czech and English titles for ${imdbId} from Wikidata SPARQL endpoint` ); - // Base SPARQL query to get film information const baseQuery = (lang) => ` SELECT ?film ?filmLabel ?originalTitle ?publicationDate ?instanceLabel WHERE { ?film wdt:P345 "${imdbId}". @@ -150,7 +158,6 @@ async function getTitleFromWikidata(imdbId) { const type = czResult.instanceLabel?.value || enResult.instanceLabel?.value || null; - // Check if the Czech title is a Wikidata entity ID (starts with Q followed by numbers) const isWikidataId = czTitle && /^Q\d+$/.test(czTitle); const validCzTitle = isWikidataId ? null : czTitle; @@ -172,11 +179,115 @@ async function getTitleFromWikidata(imdbId) { } } +// Get title information from TMDb using IMDb ID +async function getTitleFromTMDb(imdbId) { + try { + console.log(`Fetching title information for ${imdbId} from TMDb API`); + + const url = `${TMDB_BASE}/find/${imdbId}?api_key=${TMDB_API_KEY}&language=${LANGUAGE}&external_source=imdb_id`; + const response = await axios.get(url); + const data = response.data; + + const movieResults = data.movie_results || []; + const tvResults = data.tv_results || []; + + if (movieResults.length > 0) { + const movie = movieResults[0]; + console.log( + `Found movie: ${movie.title} (${ + movie.release_date?.substring(0, 4) || "Unknown year" + })` + ); + return { + type: "movie", + czTitle: movie.title, + enTitle: movie.original_title, + originalTitle: movie.original_title, + year: movie.release_date?.substring(0, 4), + overview: movie.overview, + poster: movie.poster_path + ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` + : null, + backdrop: movie.backdrop_path + ? `https://image.tmdb.org/t/p/original${movie.backdrop_path}` + : null, + tmdbId: movie.id, + }; + } else if (tvResults.length > 0) { + const show = tvResults[0]; + console.log( + `Found TV show: ${show.name} (${ + show.first_air_date?.substring(0, 4) || "Unknown year" + })` + ); + return { + type: "series", + czTitle: show.name, + enTitle: show.original_name, + originalTitle: show.original_name, + year: show.first_air_date?.substring(0, 4), + overview: show.overview, + poster: show.poster_path + ? `https://image.tmdb.org/t/p/w500${show.poster_path}` + : null, + backdrop: show.backdrop_path + ? `https://image.tmdb.org/t/p/original${show.backdrop_path}` + : null, + tmdbId: show.id, + }; + } + + console.log(`No results found on TMDb for ${imdbId}`); + return null; + } catch (error) { + console.error( + `Error fetching title information from TMDb for ${imdbId}:`, + error + ); + return null; + } +} + +// Get episode information from TMDb using TMDb ID +async function getEpisodeFromTMDb(tmdbId, season, episode) { + if (!TMDB_API_KEY || !tmdbId) return null; + + try { + console.log( + `Fetching episode S${season}E${episode} info for TMDb ID ${tmdbId}` + ); + const url = `${TMDB_BASE}/tv/${tmdbId}/season/${season}/episode/${episode}?api_key=${TMDB_API_KEY}&language=${LANGUAGE}`; + const response = await axios.get(url); + const data = response.data; + + if (!data || !data.name) { + console.log(`No episode data found for S${season}E${episode}`); + return null; + } + + console.log( + `Found episode: ${data.name} (Air date: ${data.air_date || "Unknown"})` + ); + return { + name: data.name, + overview: data.overview, + airDate: data.air_date, + episodeNumber: data.episode_number, + seasonNumber: data.season_number, + still: data.still_path + ? `https://image.tmdb.org/t/p/original${data.still_path}` + : null, + }; + } catch (error) { + console.error(`Error fetching episode information from TMDb:`, error); + return null; + } +} + // Helper to extract season/episode patterns function getSeasonEpisodePatterns(season, episode) { const seasonStr = season.toString().padStart(2, "0"); const episodeStr = episode.toString().padStart(2, "0"); - // Add more flexible patterns for fallback return [ `S${seasonStr}E${episodeStr}`, `${seasonStr}x${episodeStr}`, @@ -205,7 +316,6 @@ async function searchSeriesWithPattern(queries, season, episode) { for (const query of queries) { if (!query) continue; const results = await searchHellspy(query); - // Try strict patterns first (SxxExx, xxXxx) let filtered = results.filter((r) => patterns .slice(0, 2) @@ -245,16 +355,24 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { number: parseInt(parts[2]), }; } - // If the type is not provided, try to determine it from the ID if (!name && id.startsWith("tt")) { - const titleInfo = await getTitleFromWikidata(id); + let titleInfo = null; + + if (USE_TMDB) { + titleInfo = await getTitleFromTMDb(id); + } + + if (!titleInfo) { + titleInfo = await getTitleFromWikidata(id); + } + if (titleInfo) { name = titleInfo.czTitle || titleInfo.enTitle || titleInfo.originalTitle; year = titleInfo.year; if (!type && titleInfo.type) { - type = titleInfo.type === "movie" ? "movie" : "series"; + type = titleInfo.type; } } } @@ -289,7 +407,6 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { searchQuery = `${name} S${seasonStr}E${episodeStr}`; additionalQuery = `${name} ${seasonStr}x${episodeStr}`; - // Add anime-style "Title - XX" format which is common for anime releases const animeStyleQuery = `${name} - ${episodeStr}`; let classicBase = name.replace(/[:&]/g, "").replace(/\s+/g, " ").trim(); @@ -328,7 +445,6 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { episodeNumberOnlyQuery, simplifiedQuery, simplifiedAdditionalQuery, - // Add anime-style for simplified name simplifiedName !== name ? `${simplifiedName} - ${episode.number.toString().padStart(2, "0")}` : null, @@ -339,10 +455,26 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { queries, episode.season, episode.number - ); - // If still no results, try alternate title from Wikidata + ); // If still no results, try alternate title from TMDb or Wikidata if (results.length === 0) { - const titleInfo = await getTitleFromWikidata(id); + let titleInfo = null; + let episodeInfo = null; + + if (USE_TMDB) { + titleInfo = await getTitleFromTMDb(id); + + if (titleInfo && titleInfo.tmdbId && episode) { + episodeInfo = await getEpisodeFromTMDb( + titleInfo.tmdbId, + episode.season, + episode.number + ); + } + } + + if (!titleInfo) { + titleInfo = await getTitleFromWikidata(id); + } if ( titleInfo && titleInfo.enTitle && @@ -354,17 +486,31 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { : null; const seasonStr = episode.season.toString().padStart(2, "0"); const episodeStr = episode.number.toString().padStart(2, "0"); + + // Add specific episode title to improve search if available + const episodeTitle = episodeInfo?.name; + const episodeQueries = []; + + if (episodeTitle) { + console.log(`Adding episode title to search: "${episodeTitle}"`); + episodeQueries.push(`${altName} ${episodeTitle}`, episodeTitle); + + if (altSimplified) { + episodeQueries.push(`${altSimplified} ${episodeTitle}`); + } + } + const altQueries = [ `${altName} S${seasonStr}E${episodeStr}`, `${altName} ${seasonStr}x${episodeStr}`, - // Add anime-style format for alternate title `${altName} - ${episodeStr}`, + // Add episode title queries if available + ...episodeQueries, ]; if (altSimplified) { altQueries.push( `${altSimplified} S${seasonStr}E${episodeStr}`, `${altSimplified} ${seasonStr}x${episodeStr}`, - // Add anime-style format for simplified alternate title `${altSimplified} - ${episodeStr}` ); } @@ -388,17 +534,29 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { } if (results.length === 0 && classicQuery) { results = await searchHellspy(classicQuery); - } - // If no results found, try searching with the simplified name + } // If no results found, try searching with the simplified name if (results.length === 0) { - const titleInfo = await getTitleFromWikidata(id); + let titleInfo = null; + + // Try TMDb API first if API key is available + if (USE_TMDB) { + titleInfo = await getTitleFromTMDb(id); + } + + // Fall back to Wikidata if no results from TMDb or if TMDb is not available + if (!titleInfo) { + titleInfo = await getTitleFromWikidata(id); + } + if ( titleInfo && titleInfo.enTitle && titleInfo.enTitle !== originalName ) { console.log( - `Trying alternate title from Wikidata: "${titleInfo.enTitle}"` + `Trying alternate title from ${USE_TMDB ? "TMDb" : "Wikidata"}: "${ + titleInfo.enTitle + }"` ); if (type === "series" && episode) { @@ -480,7 +638,6 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { } } - // Filter results based on the season and episode information const patterns = getSeasonEpisodePatterns(episode.season, episode.number); results = genericResults.filter((result) => { if (!result.title) return false; @@ -489,7 +646,6 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { }); } - // If no results found, try searching with the original name if (results.length === 0) { console.log("No matching streams found"); return { streams: [] }; @@ -512,7 +668,6 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { (result) => result.title && exactAnimeRegex.test(result.title) ); - // If we found exact anime-style matches, prioritize those if (animeMatches.length > 0) { console.log( `Found ${animeMatches.length} results with anime-style pattern "${exactAnimePattern}"` diff --git a/package-lock.json b/package-lock.json index b55c507..b5551bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "stremio-hellspy-addon", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stremio-hellspy-addon", - "version": "0.0.1", + "version": "0.1.0", "license": "GPL-2.0", "dependencies": { - "axios": "^1.4.0", - "cheerio": "^1.0.0-rc.12", - "node-fetch": "^2.6.11", + "axios": "^0.24.0", + "dotenv": "^16.5.0", "stremio-addon-sdk": "^1.6.10" + }, + "engines": { + "node": ">=14.0.0" } }, "node_modules/accepts": { @@ -59,19 +61,12 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, "node_modules/axios": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", - "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.14.4" } }, "node_modules/body-parser": { @@ -108,11 +103,6 @@ "node": ">=0.10.0" } }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -166,46 +156,6 @@ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" }, - "node_modules/cheerio": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", - "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", - "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "encoding-sniffer": "^0.2.0", - "htmlparser2": "^9.1.0", - "parse5": "^7.1.2", - "parse5-htmlparser2-tree-adapter": "^7.0.0", - "parse5-parser-stream": "^7.1.2", - "undici": "^6.19.5", - "whatwg-mimetype": "^4.0.0" - }, - "engines": { - "node": ">=18.17" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" - } - }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "node_modules/cli-cursor": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", @@ -235,17 +185,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -290,32 +229,6 @@ "node": ">= 0.10" } }, - "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -324,14 +237,6 @@ "ms": "2.0.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -349,55 +254,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dependencies": { - "domelementtype": "^2.3.0" - }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", "engines": { - "node": ">= 4" + "node": ">=12" }, "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "url": "https://dotenvx.com" } }, "node_modules/dunder-proto": { @@ -426,29 +291,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding-sniffer": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", - "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", - "dependencies": { - "iconv-lite": "^0.6.3", - "whatwg-encoding": "^3.1.1" - }, - "funding": { - "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -476,20 +318,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -627,20 +455,6 @@ } } }, - "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -730,20 +544,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -755,24 +555,6 @@ "node": ">= 0.4" } }, - "node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -788,17 +570,6 @@ "node": ">= 0.8" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -982,17 +753,6 @@ } } }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1053,51 +813,6 @@ "node": ">=0.10.0" } }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", - "dependencies": { - "domhandler": "^5.0.3", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-parser-stream": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", - "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", - "dependencies": { - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1123,11 +838,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -1527,14 +1237,6 @@ "node": ">= 0.6" } }, - "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", - "engines": { - "node": ">=18.17" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1564,25 +1266,6 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "engines": { - "node": ">=18" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index 30d2476..e311553 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,13 @@ { "name": "stremio-hellspy-addon", - "version": "0.0.1", - "description": "Hellspy.to addon for Stremio", + "version": "0.1.0", + "description": "Hellspy.to addon for Stremio with TMDb metadata", "main": "addon.js", "scripts": { - "start": "node server.js" + "start": "node server.js", + "setup-tmdb": "node setup-tmdb.js", + "start:dev": "nodemon server.js", + "test:env": "node test-env.js" }, "keywords": [ "stremio", @@ -16,8 +19,9 @@ "author": "", "license": "GPL-2.0", "dependencies": { - "stremio-addon-sdk": "^1.6.10", - "axios": "^0.24.0" + "axios": "^0.24.0", + "dotenv": "^16.5.0", + "stremio-addon-sdk": "^1.6.10" }, "engines": { "node": ">=14.0.0" diff --git a/server.js b/server.js index 824a1ec..de807fb 100644 --- a/server.js +++ b/server.js @@ -1,13 +1,25 @@ #!/usr/bin/env node +// Load environment variables from .env file +require('dotenv').config(); + const { serveHTTP, publishToCentral } = require("stremio-addon-sdk"); const addonInterface = require("./addon"); const PORT = process.env.PORT || 7000; +const TMDB_API_KEY = process.env.TMDB_API_KEY; + +// Display startup configuration information +console.log(` +===== Stremio Hellspy.to Addon ===== +TMDb API: ${TMDB_API_KEY ? 'ENABLED' : 'DISABLED - metadata will fallback to Wikidata'} +Starting server on port ${PORT}... +`); serveHTTP(addonInterface, { port: PORT }) .then(({ url }) => { console.log(`Addon running at ${url}`); + console.log(`Add to Stremio: ${url}/manifest.json`); // Uncomment to publish to Stremio Central // publishToCentral("https://my-addon.glitch.me/manifest.json"); diff --git a/setup-tmdb.js b/setup-tmdb.js new file mode 100644 index 0000000..67360d9 --- /dev/null +++ b/setup-tmdb.js @@ -0,0 +1,66 @@ +// Setup script for TMDb API configuration +const axios = require("axios"); +const fs = require("fs"); +const path = require("path"); +const readline = require("readline"); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +async function testApiKey(apiKey) { + try { + const response = await axios.get( + `https://api.themoviedb.org/3/movie/550?api_key=${apiKey}&language=cs-CZ` + ); + return response.status === 200; + } catch (error) { + console.error("Error testing TMDb API key:", error.message); + return false; + } +} + +async function createEnvFile(apiKey) { + const envPath = path.join(__dirname, ".env"); + const envContent = `TMDB_API_KEY=${apiKey}`; + + try { + fs.writeFileSync(envPath, envContent); + console.log(`Successfully created .env file at ${envPath}`); + } catch (error) { + console.error("Error creating .env file:", error.message); + } +} + +console.log("=== TMDb API Setup for Stremio Hellspy Addon ==="); + +rl.question("Please enter your TMDb API key: ", async (apiKey) => { + apiKey = apiKey.trim(); + + if (!apiKey) { + console.log("No API key provided. Setup canceled."); + rl.close(); + return; + } + + console.log("Testing API key..."); + const isValid = await testApiKey(apiKey); + + if (!isValid) { + console.log("API key test failed. Please check your key and try again."); + rl.close(); + return; + } + console.log("API key is valid!"); + + // Create configuration file + await createEnvFile(apiKey); + + console.log("\nSetup completed successfully!"); + console.log("You can now run the addon with the TMDb API enabled using:"); + console.log("1. Run the server with: npm start"); + console.log("2. The .env file will be automatically loaded by the server"); + + rl.close(); +}); diff --git a/test-env.js b/test-env.js new file mode 100644 index 0000000..3df8b4d --- /dev/null +++ b/test-env.js @@ -0,0 +1,28 @@ +// Test script to verify that environment variables are properly loaded + +require("dotenv").config(); +console.log("===== ENVIRONMENT VARIABLES DIAGNOSTIC ====="); +console.log(`NODE_ENV: ${process.env.NODE_ENV || "(not set)"}`); +console.log( + `TMDB_API_KEY: ${process.env.TMDB_API_KEY ? "(set)" : "(not set)"}` +); + +// Check the actual key value (first 4 characters only for security) +if (process.env.TMDB_API_KEY) { + const key = process.env.TMDB_API_KEY; + console.log( + `TMDB_API_KEY first 4 chars: ${key.substring(0, 4)}${"*".repeat( + key.length - 4 + )}` + ); +} + +console.log('\nIf you see "(not set)" for TMDB_API_KEY, try:'); +console.log("1. Check your .env file exists in the project root directory"); +console.log( + "2. Verify the .env file contains TMDB_API_KEY=yourkey (no quotes)" +); +console.log("3. Run the setup-tmdb.js script again to create the .env file"); +console.log( + "4. Try running with the start-with-tmdb.bat or start-with-tmdb.ps1 scripts" +); From 2214c8b86dfd27ff6cf2b6e636b3f021d39f762f Mon Sep 17 00:00:00 2001 From: tomasalias Date: Mon, 26 May 2025 16:35:35 +0200 Subject: [PATCH 2/2] final version --- addon.js | 152 ++++++++++++++++++++++++++++++++++++----- title-utils.js | 182 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+), 16 deletions(-) create mode 100644 title-utils.js diff --git a/addon.js b/addon.js index 824e423..640eeca 100644 --- a/addon.js +++ b/addon.js @@ -288,34 +288,85 @@ async function getEpisodeFromTMDb(tmdbId, season, episode) { function getSeasonEpisodePatterns(season, episode) { const seasonStr = season.toString().padStart(2, "0"); const episodeStr = episode.toString().padStart(2, "0"); + const nonPaddedEpisode = parseInt(episodeStr, 10); + return [ + // Standard TV formats `S${seasonStr}E${episodeStr}`, `${seasonStr}x${episodeStr}`, - ` - ${episodeStr}`, // Format found on PC: "Title - 01" - ` - ${parseInt(episodeStr, 10)}`, // Non-zero padded version: "Title - 1" + + // Anime-specific formats + ` - ${episodeStr}`, // "Title - 01" + ` - ${nonPaddedEpisode}`, // "Title - 1" + `#${episodeStr}`, // "Title #01" + `#${nonPaddedEpisode}`, // "Title #1" + + // Common descriptive formats `Ep. ${episodeStr}`, `Ep ${episodeStr}`, `Episode ${episodeStr}`, - ` ${episodeStr} `, // surrounded by spaces - ` ${parseInt(episodeStr, 10)} `, // non-padded + `Episode ${nonPaddedEpisode}`, + + // Standalone numbers (surrounded by spaces or symbols) + ` ${episodeStr} `, + ` ${nonPaddedEpisode} `, + `[${episodeStr}]`, + `[${nonPaddedEpisode}]`, + + // Japanese style episode notation + `第${nonPaddedEpisode}話`, // "dai X wa" format + `第${nonPaddedEpisode}集`, // "dai X shu" format ]; } +// Import title utilities +const { + getStaticAnimeNameVariations, + getAllTitleVariations, +} = require("./title-utils"); + +// Helper function to get common anime name variations +// This is kept for backward compatibility +function getAnimeNameVariations(title) { + return getStaticAnimeNameVariations(title); +} + function isLikelyEpisode(title) { if (!title) return false; const upperTitle = title.toUpperCase(); + return ( - /\bS\d{2}E\d{2}\b/.test(upperTitle) || - /\b\d{1,2}x\d{1,2}\b/.test(upperTitle) || - /\s-\s\d{1,2}\b/.test(upperTitle) // "Title - 01" format + // Standard TV formats + /\bS\d{1,2}E\d{1,2}\b/.test(upperTitle) || // S01E01, S1E1 + /\b\d{1,2}x\d{1,2}\b/.test(upperTitle) || // 01x01, 1x1 + // Common anime formats + /\s-\s\d{1,2}\b/.test(upperTitle) || // "Title - 01" + /\s#\d{1,2}\b/.test(upperTitle) || // "Title #01" + /\[(\s)?\d{1,2}(\s)?\]/.test(upperTitle) || // "[01]" or "[ 01 ]" + // Japanese style formats + /第\d{1,2}[話集]/.test(title) || // "第1話" (episode 1) + // Other common patterns + /\bEP\.?\s\d{1,2}\b/i.test(upperTitle) || // "EP 01", "Ep. 01" + /\bEPISODE\s\d{1,2}\b/i.test(upperTitle) // "Episode 01" ); } async function searchSeriesWithPattern(queries, season, episode) { const patterns = getSeasonEpisodePatterns(season, episode); + const allResults = []; + + // Track which queries we've already tried to avoid duplicates + const triedQueries = new Set(); + for (const query of queries) { - if (!query) continue; + if (!query || triedQueries.has(query.toLowerCase())) continue; + triedQueries.add(query.toLowerCase()); + + console.log(`Trying query: "${query}"`); const results = await searchHellspy(query); + allResults.push(...results); + + // Try standard episode patterns first (S01E01, 01x01) let filtered = results.filter((r) => patterns .slice(0, 2) @@ -323,7 +374,7 @@ async function searchSeriesWithPattern(queries, season, episode) { ); if (filtered.length > 0) return filtered; - // Try "Title - XX" format specifically + // Try "Title - XX" format specifically (common in anime) filtered = results.filter((r) => patterns .slice(2, 4) @@ -339,6 +390,26 @@ async function searchSeriesWithPattern(queries, season, episode) { ); if (filtered.length > 0) return filtered; } + + // If we have results but couldn't find matching episodes, + // try one more pass looking for any episode pattern in all results + if (allResults.length > 0) { + console.log( + `Found ${allResults.length} total results, checking for episode patterns` + ); + const filtered = allResults.filter((r) => + patterns.some( + (p) => r.title && r.title.toUpperCase().includes(p.toUpperCase()) + ) + ); + if (filtered.length > 0) { + console.log( + `Found ${filtered.length} matching episodes in combined results` + ); + return filtered; + } + } + return []; } @@ -436,11 +507,27 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { let results = []; if (type === "series" && episode) { - // Try all queries in order, including anime-style format - const queries = [ + // Get anime name variations to improve search results + const animeVariations = getAnimeNameVariations(name); + const simplifiedVariations = + simplifiedName !== name ? getAnimeNameVariations(simplifiedName) : []; + + console.log( + `Searching with ${animeVariations.length} name variations for "${name}"` + ); + if (animeVariations.length > 1) { + console.log( + `Alternative names: ${animeVariations.slice(0, 3).join(", ")}${ + animeVariations.length > 3 ? "..." : "" + }` + ); + } + + // Base queries with original name + const baseQueries = [ searchQuery, additionalQuery, - // Include anime-style "Title - XX" format which is common for anime releases + // Anime-style query `${name} - ${episode.number.toString().padStart(2, "0")}`, episodeNumberOnlyQuery, simplifiedQuery, @@ -450,7 +537,26 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { : null, classicQuery, simplifiedEpisodeNumberOnlyQuery, - ].filter(Boolean); // Remove null values + ]; + + // Add queries with anime name variations + const variationQueries = []; + const seasonStr = episode.season.toString().padStart(2, "0"); + const episodeStr = episode.number.toString().padStart(2, "0"); + + for (const variation of animeVariations) { + if (variation !== name && variation !== simplifiedName) { + variationQueries.push( + `${variation} S${seasonStr}E${episodeStr}`, + `${variation} ${seasonStr}x${episodeStr}`, + `${variation} - ${episodeStr}`, + `${variation} ${episodeStr}` + ); + } + } + + // Combine all queries and remove duplicates/null values + const queries = [...baseQueries, ...variationQueries].filter(Boolean); results = await searchSeriesWithPattern( queries, episode.season, @@ -615,18 +721,32 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { `No results found with specific episode query, trying generic search...` ); + // Get anime name variations to enhance generic search + const animeVariations = getAnimeNameVariations(originalName); + console.log( + `Trying generic search with ${animeVariations.length} name variations` + ); + // Try multiple search approaches for generic search - const genericQueries = [ + const baseQueries = [ originalName, // Original full name simplifiedName, // Simplified name (without subtitle) originalName.split(" ").slice(0, 2).join(" "), // First two words only simplifiedName.split(" ").slice(0, 2).join(" "), // First two words of simplified name + ]; + + // Add alternative anime names to generic search queries + const allGenericQueries = [ + ...baseQueries, + ...animeVariations.filter( + (v) => v !== originalName && v !== simplifiedName + ), ].filter((q, i, self) => q && self.indexOf(q) === i); // Remove duplicates and empties let genericResults = []; // Try each generic query - for (const query of genericQueries) { + for (const query of allGenericQueries) { console.log(`Searching for generic title: "${query}"`); const queryResults = await searchHellspy(query); genericResults.push(...queryResults); @@ -637,7 +757,7 @@ builder.defineStreamHandler(async ({ type, id, name, episode, year }) => { break; // Stop if we found results } } - + // Filter results based on the season and episode information const patterns = getSeasonEpisodePatterns(episode.season, episode.number); results = genericResults.filter((result) => { if (!result.title) return false; diff --git a/title-utils.js b/title-utils.js new file mode 100644 index 0000000..6ff5841 --- /dev/null +++ b/title-utils.js @@ -0,0 +1,182 @@ +// Helper functions for handling title variations and translations +const axios = require("axios"); + +/** + * Get alternative titles for a movie or series from TMDb API + * @param {string} name Original title name + * @param {string} type Content type (movie or series) + * @param {string} apiKey TMDb API key + * @param {string} language Primary language for results (e.g., "cs-CZ") + * @returns {Promise} Array of alternative titles + */ +async function getAlternativeTitlesFromTMDb(name, type, apiKey, language = "cs-CZ") { + if (!apiKey || !name) return []; + + try { + console.log(`Searching TMDb for alternative titles for "${name}" (${type})`); + + // Step 1: Search for the title on TMDb + const searchType = type === "series" ? "tv" : "movie"; + const searchUrl = `https://api.themoviedb.org/3/search/${searchType}?api_key=${apiKey}&query=${encodeURIComponent(name)}&language=${language}`; + + const searchResponse = await axios.get(searchUrl); + const results = searchResponse.data.results || []; + + if (results.length === 0) { + console.log(`No TMDb results found for "${name}"`); + return []; + } + + // Get the first matching result + const item = results[0]; + const itemId = item.id; + + // Step 2: Get alternative titles using the TMDb ID + const altTitlesUrl = `https://api.themoviedb.org/3/${searchType}/${itemId}/alternative_titles?api_key=${apiKey}`; + const altResponse = await axios.get(altTitlesUrl); + + // Extract all titles + const titles = []; + + // Add original title and name + if (type === "series") { + titles.push(item.name); + if (item.original_name && item.original_name !== item.name) { + titles.push(item.original_name); + } + } else { + titles.push(item.title); + if (item.original_title && item.original_title !== item.title) { + titles.push(item.original_title); + } + } + + // Add alternative titles + if (altResponse.data.titles) { + altResponse.data.titles.forEach(title => { + titles.push(title.title); + }); + } + + // Remove duplicates + const uniqueTitles = [...new Set(titles)].filter(Boolean); + + console.log(`Found ${uniqueTitles.length} alternative titles for "${name}"`); + return uniqueTitles; + } catch (error) { + console.error(`Error fetching alternative titles for "${name}":`, error.message); + return []; + } +} + +/** + * Combined function that merges hardcoded anime mappings with dynamic TMDb lookups + * @param {string} title Original title + * @param {string} type Content type (movie or series) + * @param {string} tmdbApiKey TMDb API key (optional) + * @returns {Promise} Array of title variations + */ +async function getAllTitleVariations(title, type, tmdbApiKey) { + if (!title) return []; + + // Start with static mappings + const staticVariations = getStaticAnimeNameVariations(title); + + // If TMDb API key is provided, get dynamic variations too + let dynamicVariations = []; + if (tmdbApiKey) { + try { + dynamicVariations = await getAlternativeTitlesFromTMDb(title, type, tmdbApiKey); + } catch (error) { + console.error("Error getting dynamic title variations:", error.message); + } + } + + // Combine results, remove duplicates, and ensure original title is included + const allVariations = [...staticVariations, ...dynamicVariations, title]; + return [...new Set(allVariations)].filter(Boolean); +} + +/** + * Static mappings of common anime names (fallback when TMDb API is unavailable) + */ +function getStaticAnimeNameVariations(title) { + if (!title) return []; + + // Common anime name substitutions to improve search results + const commonMappings = { + "Sósó no Frieren": ["Frieren", "Frieren Beyond Journeys End", "Sousou no Frieren", "Frieren: Beyond Journey's End"], + "葬送のフリーレン": ["Frieren", "Frieren Beyond Journeys End", "Sousou no Frieren", "Frieren: Beyond Journey's End"], + "Sousou no Frieren": ["Frieren", "Frieren Beyond Journeys End", "Frieren: Beyond Journey's End"], + "Frieren: Beyond Journey's End": ["Frieren", "Sousou no Frieren"], + "Spy×Family": ["Spy Family", "SpyFamily", "Spy x Family"], + "Jujutsu Kaisen": ["JJK"], + "Boku no Hero Academia": ["My Hero Academia", "MHA"], + "Shingeki no Kyojin": ["Attack on Titan", "AOT"], + "Kimetsu no Yaiba": ["Demon Slayer"], + "One Piece": ["ワンピース", "Wan Pīsu"], + "Naruto": ["ナルト"], + "Dragon Ball": ["ドラゴンボール", "Doragon Bōru"], + "Bleach": ["ブリーチ", "Burīchi"], + "Hunter x Hunter": ["Hunter × Hunter", "HxH", "ハンターハンター"], + "Fullmetal Alchemist": ["Fullmetal Alchemist: Brotherhood", "FMA", "FMA:B", "鋼の錬金術師"], + "Death Note": ["デスノート", "Desu Nōto"], + "Tokyo Ghoul": ["東京喰種", "Tōkyō Gūru"], + "Attack on Titan": ["Shingeki no Kyojin", "AOT", "進撃の巨人"], + "Demon Slayer": ["Kimetsu no Yaiba", "鬼滅の刃"], + "My Hero Academia": ["Boku no Hero Academia", "MHA", "僕のヒーローアカデミア"], + "One Punch Man": ["ワンパンマン", "Wanpanman"], + "Vinland Saga": ["ヴィンランド・サガ"], + "Chainsaw Man": ["チェンソーマン", "Chensō Man"], + "Bocchi the Rock!": ["ぼっち・ざ・ろっく!"], + "Solo Leveling": ["나 혼자만 레벨업", "Na Honjaman Level Up", "I Level Up Alone"], + "Oshi no Ko": ["【推しの子】", "My Star"], + "Jigokuraku": ["Hell's Paradise", "地獄楽"] + }; + + // Check for direct matches in our mapping + const variations = []; + const titleLower = title.toLowerCase(); + + for (const [key, values] of Object.entries(commonMappings)) { + // Check if the title or any part of it matches the key + if ( + titleLower.includes(key.toLowerCase()) || + values.some(v => titleLower.includes(v.toLowerCase())) + ) { + // Add all variations for this title + variations.push(...values); + // Also add the key as a variation if it's not the original title + if (key.toLowerCase() !== titleLower) { + variations.push(key); + } + } + } + + // Check for partial matches in words (useful for anime with multiple name formats) + const titleWords = title.toLowerCase().split(/\s+/); + for (const [key, values] of Object.entries(commonMappings)) { + const keyWords = key.toLowerCase().split(/\s+/); + // Check for overlap in words + const hasCommonWords = keyWords.some(word => + titleWords.includes(word) && word.length > 3 // Only consider substantial words + ); + + if (hasCommonWords) { + variations.push(...values); + variations.push(key); + } + } + + // Add current title to variations + variations.push(title); + + // Remove duplicates and filter out empty strings + return [...new Set(variations)].filter(Boolean); +} + +module.exports = { + getAlternativeTitlesFromTMDb, + getStaticAnimeNameVariations, + getAllTitleVariations +};