├── .gitignore ├── LICENSE ├── README.md ├── addon.js ├── ecosystem.config.js ├── favicon.ico ├── index.html ├── package.json ├── public ├── bg.jpg ├── bmc.png ├── configure.html ├── logo.png ├── logo_orig.png └── redirect.png ├── server.js └── utils ├── apiRetry.js ├── crypto.js ├── issueHandler.js └── logger.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | package-lock.json 4 | 5 | # Environment variables 6 | .env 7 | 8 | # Logs 9 | *.log 10 | logs/ 11 | 12 | # IDE specific files 13 | .idea/ 14 | .vscode/ 15 | *.swp 16 | *.swo 17 | .DS_Store 18 | 19 | # Unnecessary files 20 | index.html 21 | howto.txt 22 | 23 | # Server specific 24 | monitor.sh -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | AI Search 3 |
4 | 5 | # Stremio AI Search 6 | 7 | An intelligent search addon for Stremio powered by Google's Gemini AI. Get personalized movie and TV series recommendations based on natural language queries. 8 | 9 | Recommendations served 10 | 11 | ## Features 12 | 13 | - Trakt integration to help Gemini suggest personalized recommendations. Note: Only searches starting with "Recommend" will provide personalized recommendations using your watch history from Trakt. 14 | - Select any of the Google AI models in the addon configuration 15 | - You can set the number of recommendations AI should return for a query 16 | - TMDB integration ensures you have a content rich catalog for movies and series 17 | - RPDB integration gives you access to awesome posters with inbuilt ratings 18 | 19 | ## Installation 20 | 21 | 1. Visit [Addon configuration](https://stremio.itcon.au/aisearch/configure) 22 | 2. Enter your API keys 23 | 3. Provide optional parameters 24 | 4. Install 25 | 5. Buy me a coffee :) 26 |

27 | 28 | Buy Me A Coffee 29 | 30 | 31 | ## Query ideas 32 | 33 | Here are some examples showing how versatile this addon is. 34 | 35 | ### Natural Language Queries 36 | 37 | - "A heartwarming comedy about family relationships" 38 | - "Critically acclaimed movies that flopped at the box office" 39 | - "Best period dramas set in the 1800s" 40 | - "Movies that take place in one night" 41 | - "If I liked Inception, what should I watch next?" 42 | - "Movies that feel like a Black Mirror episode" 43 | - "Movies based on Stephen King novels" 44 | - "Movies under 90 minutes long" 45 | - "Movies in the Spanish language" 46 | - "Movies set in Japan" 47 | - "Movies where the protagonist has a hidden identity" 48 | - "Movies where someone fakes their own death" 49 | - "Movies to watch with pizza & beer" 50 | - "Tell me a movie I've never heard of" 51 | - "What movie should I watch with my grandmother?" 52 | 53 | ### Time Periods 54 | 55 | - "Sci-fi movies from the 80s" 56 | - "Modern crime series from 2020-2023" 57 | - "Movies released in 1977" 58 | - "90s teen comedies" 59 | - "Films that captured the spirit of the 1960s counterculture" 60 | - "Movies set in the future but made before 2000" 61 | - "Best films of each decade from 1950-2020" 62 | - "Most influential films by year since 2000" 63 | 64 | ### Genre Combinations 65 | 66 | - "Best horror movies of all time" 67 | - "Action comedy with martial arts" 68 | - "Dark mystery thriller series" 69 | - "Best indie horror films post-2015" 70 | - "Sci-Fi Western crossovers" 71 | - "Blade Runner + Film Noir" 72 | - "Romantic comedies with supernatural elements" 73 | - "Historical dramas with elements of magical realism" 74 | 75 | ### Mood & Style 76 | 77 | - "Feel-good movies for a rainy day" 78 | - "Feel-good comedies for a lazy Sunday" 79 | - "Intense psychological thrillers" 80 | - "Dark psychological thrillers from the last 5 years" 81 | - "Movies to watch when I'm feeling nostalgic" 82 | - "Movies to make me feel inspired" 83 | - "Films that capture the feeling of summer nostalgia" 84 | - "Movies with a dreamlike atmosphere" 85 | 86 | ### Similarities 87 | 88 | - "Movies with crazy plot twists like The Sixth Sense" 89 | - "Non-linear storytelling like Memento" 90 | - "Road trip movies similar to Little Miss Sunshine" 91 | - "Animated films with the emotional depth of Inside Out" 92 | - "Movies with the same vibe as Lost in Translation" 93 | 94 | ### Franchise/Studio 95 | 96 | - "Movies by A24 Studio" 97 | - "Mission Impossible movies" 98 | - "Last 5 James Bond movies" 99 | - "Studio Ghibli films suitable for young children" 100 | - "Star Wars movies in chronological order" 101 | - "Horror franchises with the most sequels" 102 | - "Netflix original documentaries" 103 | - "Pixar movies with the best animation" 104 | 105 | ### By Director/Cast 106 | 107 | - "Underrated movies by Christopher Nolan" 108 | - "Best performances of Meryl Streep" 109 | - "Movies with Ryan Gosling and Emma Stone" 110 | - "Movies directed by female directors in the 2010s" 111 | - "Films where comedians play serious roles" 112 | - "Movies where the director also stars as the main character" 113 | - "Films featuring actors who won Oscars for their roles" 114 | - "Movies with ensemble casts" 115 | 116 | ### Thematic Searches 117 | 118 | - "Revenge movies with satisfying endings" 119 | - "Films about time travel that actually make sense" 120 | - "Movies where the protagonist is the villain" 121 | - "Films exploring the concept of memory" 122 | - "Movies about unlikely friendships across generations" 123 | - "Films that deal with grief in a meaningful way" 124 | - "Movies about redemption" 125 | - "Films that explore artificial intelligence" 126 | 127 | ### Exclusion Searches 128 | 129 | - "Horror movies NOT involving supernatural elements" 130 | - "Best movies NOT by Disney" 131 | - "Sci-fi films without aliens" 132 | - "Comedies that don't rely on crude humor" 133 | - "Action movies without gun violence" 134 | - "Thrillers without plot twists" 135 | - "Romance movies without love triangles" 136 | - "Disaster films that aren't about climate change" 137 | 138 | ### Maturity Rating 139 | 140 | - "R-rated comedies from the 2000s" 141 | - "Best PG movies for family movie night" 142 | - "NC-17 films that are critically acclaimed" 143 | - "G-rated movies that adults can enjoy too" 144 | - "PG-13 action movies with minimal violence" 145 | - "TV-MA series with complex storylines" 146 | - "Films that pushed the boundaries of their rating" 147 | - "Movies that were controversially rated" 148 | 149 | ### Cultural & Historical Context 150 | 151 | - "Movies that capture the essence of 1970s New York" 152 | - "Films that accurately portray historical events in Ancient Rome" 153 | - "Movies about the fall of the Berlin Wall" 154 | - "Series that explore post-Soviet Eastern Europe" 155 | - "Films about cultural revolutions around the world" 156 | - "Movies that defined Generation X" 157 | - "Films that captured the zeitgeist of their era" 158 | - "Historical events told from multiple perspectives" 159 | 160 | ### Technical & Cinematic Elements 161 | 162 | - "Movies with exceptional cinematography in natural landscapes" 163 | - "Films shot entirely in one take" 164 | - "Movies with innovative practical effects (no CGI)" 165 | - "Films with unreliable narrators" 166 | - "Movies with fourth wall breaks" 167 | - "Films with the most impressive long tracking shots" 168 | - "Movies with experimental editing techniques" 169 | - "Films with distinctive color palettes" 170 | 171 | ### Niche Combinations 172 | 173 | - "Sci-fi horror set underwater" 174 | - "Animated films for adults with philosophical themes" 175 | - "Mockumentaries about fictional musicians" 176 | - "Heist movies with female-led casts" 177 | - "Dystopian stories that don't involve teenagers" 178 | - "Comedies set in medieval times" 179 | - "Sports films that aren't about winning the big game" 180 | - "Romantic comedies with science fiction elements" 181 | 182 | ### Emotional Impact 183 | 184 | - "Movies that will make me ugly cry" 185 | - "Films that restore faith in humanity" 186 | - "Movies that will leave me thinking for days" 187 | - "Comfort films for anxiety" 188 | - "Movies that feel like a warm hug" 189 | - "Films that will help process grief" 190 | - "Movies with unexpected emotional depth" 191 | - "Films that changed people's worldviews" 192 | 193 | ### Situational 194 | 195 | - "Movies to watch after a breakup" 196 | - "Films perfect for a first date" 197 | - "Movies to watch when you can't sleep" 198 | - "Series to binge when sick in bed" 199 | - "Films that are better on second viewing" 200 | - "Movies to watch when you're home alone" 201 | - "Films to inspire a career change" 202 | - "Movies that pair well with specific foods" 203 | 204 | ### Unconventional Parameters 205 | 206 | - "Movies where the villain wins" 207 | - "Films with no dialogue for the first 15 minutes" 208 | - "Movies where the main character dies halfway through" 209 | - "Series where the setting is almost like another character" 210 | - "Films with ambiguous endings that leave you guessing" 211 | - "Movies that take place entirely in real-time" 212 | - "Films set entirely in one location" 213 | - "Movies told in reverse chronological order" 214 | 215 | ### Surprising & Unusual Queries 216 | 217 | - "Movies where the dog doesn't die" 218 | - "Films where food plays a central role" 219 | - "Movies that accurately portray computer hacking" 220 | - "Films where the twist is that there is no twist" 221 | - "Movies set entirely in elevators or confined spaces" 222 | - "Films where the soundtrack tells the story better than dialogue" 223 | - "Movies that predicted real-world technology or events" 224 | - "Films where the background extras are more interesting than the main plot" 225 | - "Movies that are secretly about capitalism" 226 | - "Films that work better if you watch them backwards" 227 | - "Movies where the opening scene is the best part" 228 | - "Films that were shot in your hometown but set somewhere else" 229 | - "Movies where the weather is practically a character" 230 | - "Films that are actually better when watched with commentary" 231 | - "Movies that are impossible to explain to someone else" 232 | 233 | ### International & Language-Specific 234 | 235 | - "Korean thrillers similar to Parasite" 236 | - "French romantic comedies from the last decade" 237 | - "Bollywood films that break traditional formulas" 238 | - "Japanese animated films not made by Studio Ghibli" 239 | - "Scandinavian crime dramas with female detectives" 240 | - "African cinema exploring post-colonial themes" 241 | - "Latin American magical realism films" 242 | - "Middle Eastern movies about everyday life" 243 | 244 | ### Experimental & Art House 245 | 246 | - "Surrealist films that aren't too pretentious" 247 | - "Experimental movies that are still accessible to casual viewers" 248 | - "Art house films with compelling narratives" 249 | - "Movies that blend animation with live action meaningfully" 250 | - "Films that play with color theory and visual symbolism" 251 | - "Avant-garde cinema that actually tells a story" 252 | - "Experimental documentaries with unique formats" 253 | - "Films that challenge conventional narrative structure" 254 | 255 | ### Runtime & Viewing Experience 256 | 257 | - "Best movies under 90 minutes" 258 | - "Epic films that justify their long runtime" 259 | - "Movies that feel shorter than they actually are" 260 | - "Films perfect for a movie marathon" 261 | - "Short films with powerful messages" 262 | - "Movies you can watch while doing something else" 263 | - "Films that demand your full attention" 264 | - "TV series with short episodes" 265 | 266 | ### Adaptation & Source Material 267 | 268 | - "Book-to-film adaptations that improved on the source" 269 | - "Comic book movies that pleased hardcore fans" 270 | - "Video game adaptations that actually worked" 271 | - "Films based on true stories that stayed accurate" 272 | - "Movies that are better than the books they're based on" 273 | - "TV shows expanded from short films" 274 | - "Adaptations that completely changed the source material" 275 | - "Films based on obscure source material" 276 | 277 | ## Ranking Options 278 | 279 | ### Chronological Rankings 280 | 281 | - "Star Wars movies ranked by release date" 282 | - "Oscar winners for Best Picture ranked by year" 283 | - "Horror franchises ranked by longevity" 284 | - "Animated films ranked from oldest to newest" 285 | - "Movie adaptations ranked by time between book and film release" 286 | - "Film series ranked by consistency across decades" 287 | 288 | ### Rating-Based Rankings 289 | 290 | - "Highest IMDB rated movies of all time" 291 | - "Rotten Tomatoes' freshest horror films" 292 | - "Movies with the biggest gap between critic and audience scores" 293 | - "Films with perfect Metacritic scores" 294 | - "Highest-grossing movies adjusted for inflation" 295 | - "Cult classics with the lowest initial ratings" 296 | - "Movies that won the most Academy Awards" 297 | - "Films that swept all major award categories" 298 | 299 | ### Studio/Production Company Rankings 300 | 301 | - "Disney animated films ranked by box office success" 302 | - "Marvel movies ranked by critical reception" 303 | - "Netflix original series ranked by viewer ratings" 304 | - "HBO shows ranked by cultural impact" 305 | - "Blumhouse horror films ranked by scariness" 306 | - "Dreamworks animations ranked by humor" 307 | - "A24 films ranked by artistic merit" 308 | - "Warner Bros. franchises ranked by longevity" 309 | 310 | ### Director-Based Rankings 311 | 312 | - "Christopher Nolan films ranked by complexity" 313 | - "Quentin Tarantino movies ranked by dialogue quality" 314 | - "Steven Spielberg films ranked by historical accuracy" 315 | - "Martin Scorsese gangster films ranked by realism" 316 | - "Wes Anderson movies ranked by visual style" 317 | - "David Fincher thrillers ranked by plot twists" 318 | - "Greta Gerwig films ranked by feminist themes" 319 | - "Denis Villeneuve sci-fi movies ranked by visual effects" 320 | 321 | ### Actor Performance Rankings 322 | 323 | - "Tom Hanks roles ranked by dramatic range" 324 | - "Meryl Streep performances ranked by accent accuracy" 325 | - "Leonardo DiCaprio films ranked by physical transformation" 326 | - "Viola Davis performances ranked by emotional intensity" 327 | - "Jim Carrey comedies ranked by physical comedy" 328 | - "Daniel Day-Lewis roles ranked by method acting commitment" 329 | - "Cate Blanchett performances ranked by character complexity" 330 | - "Denzel Washington films ranked by monologue power" 331 | 332 | ### Technical Achievement Rankings 333 | 334 | - "Movies ranked by innovative cinematography techniques" 335 | - "Films ranked by practical effects excellence" 336 | - "Movies ranked by sound design innovation" 337 | - "Films ranked by editing complexity" 338 | - "Movies ranked by costume design authenticity" 339 | - "Films ranked by makeup transformation quality" 340 | - "Movies ranked by long-take difficulty" 341 | - "Films ranked by musical score memorability" 342 | 343 | ### Cultural Impact Rankings 344 | 345 | - "Sci-fi movies ranked by influence on real technology" 346 | - "Films ranked by quotability in popular culture" 347 | - "Movies ranked by meme generation" 348 | - "Films ranked by fashion trend influence" 349 | - "Movies ranked by political controversy caused" 350 | - "Films ranked by tourism impact on filming locations" 351 | - "Movies ranked by merchandise sales" 352 | - "Films ranked by academic study frequency" 353 | 354 | ### Audience Response Rankings 355 | 356 | - "Horror movies ranked by jump scare effectiveness" 357 | - "Comedies ranked by laugh-out-loud moments" 358 | - "Dramas ranked by tear-jerking scenes" 359 | - "Thrillers ranked by plot twist unexpectedness" 360 | - "Action movies ranked by audience adrenaline" 361 | - "Romances ranked by chemistry between leads" 362 | - "Documentaries ranked by mind-changing potential" 363 | - "Animated films ranked by adult appeal" 364 | 365 | ### Niche and Specific Rankings 366 | 367 | - "Disaster movies ranked by scientific accuracy" 368 | - "Heist films ranked by plan complexity" 369 | - "Superhero movies ranked by villain memorability" 370 | - "Time travel films ranked by paradox avoidance" 371 | - "Zombie movies ranked by survival strategy realism" 372 | - "Spy films ranked by gadget innovation" 373 | - "Sports movies ranked by inspirational speeches" 374 | - "Courtroom dramas ranked by legal accuracy" 375 | 376 | ### Budget & Box Office Rankings 377 | 378 | - "Highest ROI movies of all time" 379 | - "Blockbusters ranked by budget efficiency" 380 | - "Low-budget films with the biggest cultural impact" 381 | - "Movies that bombed financially but became classics" 382 | - "Highest-grossing independent films" 383 | - "Films that saved their studios from bankruptcy" 384 | - "Most expensive movies that flopped at the box office" 385 | - "Franchises ranked by average box office performance" 386 | 387 | ### Unconventional Rankings 388 | 389 | - "Movies ranked by rewatchability factor" 390 | - "Films ranked by 'so bad it's good' quality" 391 | - "Movies ranked by unexpected cameos" 392 | - "Films ranked by background detail richness" 393 | - "Movies ranked by fan theory generation" 394 | - "Films ranked by director's cut improvement" 395 | - "Movies ranked by soundtrack sales" 396 | - "Films ranked by sequel potential" 397 | 398 | ### Popularity Shift Rankings 399 | 400 | - "Movies that gained cult status years after release" 401 | - "Films that were ahead of their time" 402 | - "Initially panned movies now considered masterpieces" 403 | - "Critically acclaimed films that audiences hated" 404 | - "Movies whose reputation improved with director's cuts" 405 | - "Films that launched trends in cinema" 406 | - "Movies that killed their franchises" 407 | - "Forgotten classics deserving rediscovery" 408 | 409 | ## Self Hosting 410 | 411 | ### Environment Variables 412 | 413 | When self-hosting the addon, you can configure the following environment variables in a `.env` file: 414 | 415 | - `TRAKT_CLIENT_ID` - Your Trakt API client ID 416 | - `TRAKT_CLIENT_SECRET` - Your Trakt API client secret 417 | - `ENCRYPTION_KEY` - Key used for encrypting sensitive configuration data 418 | - `RPDB_API_KEY` - API key for RPDB integration 419 | - `ENABLE_LOGGING` - Set to "true" to enable logging 420 | - `GITHUB_TOKEN` - GitHub token for issue submission 421 | - `RECAPTCHA_SECRET_KEY` - Secret key for reCAPTCHA 422 | - `ADMIN_TOKEN` - Token required for accessing cache management endpoints (new) 423 | 424 | ### Admin Endpoints 425 | 426 | The addon provides several administrative endpoints for cache management. All endpoints require an admin token which should be set in the `.env` file as `ADMIN_TOKEN`. 427 | 428 | ### Cache Management 429 | 430 | All endpoints are GET requests and require the `adminToken` as a query parameter. You can run any of these endpoints directly in your browser. 431 | 432 | #### Cache Statistics 433 | 434 | ```bash 435 | GET https://stremio.itcon.au/aisearch/cache/stats?adminToken=your-admin-token 436 | ``` 437 | 438 | #### AI Cache Management 439 | 440 | ```bash 441 | # Clear all AI cache 442 | GET https://stremio.itcon.au/aisearch/cache/clear/ai?adminToken=your-admin-token 443 | 444 | # Remove specific AI cache entries by keywords 445 | GET https://stremio.itcon.au/aisearch/cache/clear/ai/keywords?adminToken=your-admin-token&keywords=ocean%20thriller 446 | ``` 447 | 448 | #### TMDB Cache Management 449 | 450 | ```bash 451 | # Clear TMDB cache 452 | GET https://stremio.itcon.au/aisearch/cache/clear/tmdb?adminToken=your-admin-token 453 | 454 | # Clear TMDB details cache 455 | GET https://stremio.itcon.au/aisearch/cache/clear/tmdb-details?adminToken=your-admin-token 456 | 457 | # Clear TMDB discover cache 458 | GET https://stremio.itcon.au/aisearch/cache/clear/tmdb-discover?adminToken=your-admin-token 459 | 460 | # List all TMDB discover cache keys 461 | GET https://stremio.itcon.au/aisearch/cache/list/tmdb-discover?adminToken=your-admin-token 462 | 463 | # Remove a specific TMDB discover cache item 464 | GET https://stremio.itcon.au/aisearch/cache/remove/tmdb-discover?key=discover_series_80_2023-09-01_en-US&adminToken=your-admin-token 465 | ``` 466 | 467 | #### Other Cache Management 468 | 469 | ```bash 470 | # Clear RPDB cache 471 | GET https://stremio.itcon.au/aisearch/cache/clear/rpdb?adminToken=your-admin-token 472 | 473 | # Clear Trakt cache 474 | GET https://stremio.itcon.au/aisearch/cache/clear/trakt?adminToken=your-admin-token 475 | 476 | # Clear Trakt raw data cache 477 | GET https://stremio.itcon.au/aisearch/cache/clear/trakt-raw?adminToken=your-admin-token 478 | 479 | # Clear query analysis cache 480 | GET https://stremio.itcon.au/aisearch/cache/clear/query-analysis?adminToken=your-admin-token 481 | 482 | # Clear all caches 483 | GET https://stremio.itcon.au/aisearch/cache/clear/all?adminToken=your-admin-token 484 | 485 | # Save all caches to files 486 | GET https://stremio.itcon.au/aisearch/cache/save?adminToken=your-admin-token 487 | ``` 488 | 489 | ### Example Usage 490 | 491 | You can use these endpoints directly in your browser by visiting: 492 | 493 | ``` 494 | https://stremio.itcon.au/aisearch/cache/clear/ai?adminToken=your-admin-token 495 | https://stremio.itcon.au/aisearch/cache/clear/ai/keywords?adminToken=your-admin-token&keywords=your search terms 496 | https://stremio.itcon.au/aisearch/cache/list/tmdb-discover?adminToken=your-admin-token 497 | https://stremio.itcon.au/aisearch/cache/remove/tmdb-discover?key=discover_series_80_2023-09-01_en-US&adminToken=your-admin-token 498 | https://stremio.itcon.au/aisearch/cache/clear/all?adminToken=your-admin-token 499 | ``` 500 | 501 | ### Response Examples 502 | 503 | **Keywords-based cache removal response:** 504 | 505 | ```json 506 | { 507 | "removed": 2, 508 | "entries": [ 509 | { 510 | "key": "ocean thriller_movie_no_trakt", 511 | "timestamp": "2024-03-20T12:34:56.789Z", 512 | "query": "ocean thriller" 513 | } 514 | ] 515 | } 516 | ``` 517 | 518 | **TMDB discover cache list response:** 519 | 520 | ```json 521 | { 522 | "success": true, 523 | "count": 3, 524 | "keys": [ 525 | "discover_series_80_2023-09-01_en-US", 526 | "discover_movie_28_2024-01-01_en-US", 527 | "discover_series_18_2023-03-01_en-US" 528 | ] 529 | } 530 | ``` 531 | 532 | **TMDB discover cache item removal response:** 533 | 534 | ```json 535 | { 536 | "success": true, 537 | "message": "Cache item removed successfully", 538 | "key": "discover_series_80_2023-09-01_en-US" 539 | } 540 | ``` 541 | 542 | **General cache clearing response:** 543 | 544 | ```json 545 | { 546 | "cleared": true, 547 | "previousSize": 42 548 | } 549 | ``` 550 | 551 | **Clear all caches response:** 552 | 553 | ```json 554 | { 555 | "tmdb": { "cleared": true, "previousSize": 15 }, 556 | "tmdbDetails": { "cleared": true, "previousSize": 10 }, 557 | "tmdbDiscover": { "cleared": true, "previousSize": 8 }, 558 | "ai": { "cleared": true, "previousSize": 42 }, 559 | "rpdb": { "cleared": true, "previousSize": 8 }, 560 | "trakt": { "cleared": true, "previousSize": 12 }, 561 | "traktRaw": { "cleared": true, "previousSize": 5 }, 562 | "queryAnalysis": { "cleared": true, "previousSize": 20 } 563 | } 564 | ``` 565 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | apps: [ 5 | { 6 | name: "stremio-ai-addon", 7 | script: "./server.js", 8 | cwd: ".", 9 | env: { 10 | NODE_ENV: "production", 11 | PORT: 7000, 12 | HOST: "0.0.0.0", 13 | }, 14 | max_memory_restart: "999M", // Restart if memory exceeds 300MB 15 | instances: 1, // Changed to 1 instance to avoid port conflicts 16 | exec_mode: "fork", // Changed to fork mode 17 | log_date_format: "YYYY-MM-DD HH:mm:ss [Australia/Melbourne]", 18 | error_file: "./logs/error.log", 19 | out_file: "./logs/out.log", 20 | merge_logs: true, 21 | autorestart: true, // Auto restart if app crashes 22 | restart_delay: 4000, // Delay between automatic restarts 23 | max_restarts: 10, // Number of times to restart before stopping 24 | exp_backoff_restart_delay: 100, // Delay between restarts 25 | min_uptime: "30s", 26 | listen_timeout: 8000, 27 | kill_timeout: 5000, 28 | }, 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itcon-pty-au/stremio-ai-search/3ed435f06694a9d6091076e2adfe1de904524b2b/favicon.ico -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Stremio AI Search 5 | 6 | 150 | 151 | 152 |
153 | 154 |

Stremio AI Search

155 | 156 |

157 | An intelligent search addon for Stremio powered by Google's Gemini AI. 158 | Get personalized movie and TV series recommendations based on natural 159 | language queries. 160 |

161 | 162 |
163 |

Example Searches

164 | 165 |
166 |

Natural Language

167 |
168 | "A heartwarming comedy about family relationships" 169 |
170 |

171 | Search using natural language descriptions of what you want to 172 | watch. 173 |

174 |
175 | 176 |
177 |

Time Periods

178 |
"Sci-fi movies from the 80s"
179 |
"Modern crime series from 2020-2023"
180 |

Specify time periods or years for more targeted results.

181 |
182 | 183 |
184 |

Genre Combinations

185 |
"Action comedy with martial arts"
186 |
"Dark mystery thriller series"
187 |

Combine multiple genres and themes.

188 |
189 | 190 |
191 |

Mood & Style

192 |
"Feel-good movies for a rainy day"
193 |
"Intense psychological thrillers"
194 |

Search based on mood or emotional impact.

195 |
196 |
197 | 198 |
199 | 202 |
203 |
204 | 227 | 228 | 229 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stremio-ai-addon", 3 | "version": "1.0.0", 4 | "description": "AI-powered movie and series recommendations for Stremio", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "start:dev": "ENABLE_LOGGING=true node server.js" 9 | }, 10 | "dependencies": { 11 | "@google/generative-ai": "^0.1.x", 12 | "compression": "^1.7.4", 13 | "express": "^4.18.x", 14 | "express-rate-limit": "^7.1.5", 15 | "node-fetch": "^2.7.x", 16 | "stremio-addon-sdk": "^1.6.x", 17 | "lru-cache": "^10.0.1", 18 | "dotenv": "^16.3.1" 19 | }, 20 | "engines": { 21 | "node": ">=16.0.0" 22 | }, 23 | "author": "ITCON", 24 | "license": "MIT", 25 | "private": true 26 | } 27 | -------------------------------------------------------------------------------- /public/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itcon-pty-au/stremio-ai-search/3ed435f06694a9d6091076e2adfe1de904524b2b/public/bg.jpg -------------------------------------------------------------------------------- /public/bmc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itcon-pty-au/stremio-ai-search/3ed435f06694a9d6091076e2adfe1de904524b2b/public/bmc.png -------------------------------------------------------------------------------- /public/configure.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | AI Search Addon Configuration 5 | 6 | 532 | 533 | 534 |
535 | 536 |

Stremio AI Search

537 | 538 |
539 | Stremio AI Search Queries 543 |
544 | 545 |
546 | 547 |
548 | 551 | 556 |
557 | Get a free key from 558 | Google AI Studio 561 |
562 |
563 | 564 |
565 | 568 | 573 |
574 | Get a free key from 575 | TMDB 578 |
579 |
580 | 581 | 582 |
583 |
584 | 585 | 586 |
587 |
588 | 589 | 590 |
591 |
592 |
593 | 594 | 595 | 600 |
601 |
602 | 603 | 604 |
605 |
606 | 607 | 610 |
611 |
612 | Requires an RPDB API Key for personalized posters. If enabled 613 | without a key, may use cached/default posters. 614 |
615 |
616 | 617 | 618 | 632 | 633 | 634 | 650 | 651 |
652 | 653 | 661 | 666 |
667 | Connect your Trakt.tv account to get personalized movie 668 | recommendations based on your watch history and ratings. 669 |
670 | Note: You'll need to re-authenticate every 90 days for security 672 | reasons. 674 |
675 |
676 | 677 |
678 | 681 | 760 |
761 | Select the language for movie and series information 762 |
763 |
764 | 765 |
766 | 769 | 775 |
776 | Enter the Gemini model id. See available models at 777 | 781 | Gemini Models Documentation 782 | 783 |
784 |
785 | 786 |
787 | 790 | 799 |
800 | Higher values may increase response time 801 |
802 |
803 |
804 | 805 |
806 | 809 | 812 |
813 | 814 |
815 |
816 |
817 | URL copied to clipboard! 818 |
819 | 820 | 825 | Buy me a coffee 826 | 827 | 828 | 829 | 836 | 837 | 838 |
960 | 961 |
962 | 963 | 986 | 987 | 1862 | 1863 | 1864 | 1865 | 1866 | 1867 | 1873 | 1874 | 1875 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itcon-pty-au/stremio-ai-search/3ed435f06694a9d6091076e2adfe1de904524b2b/public/logo.png -------------------------------------------------------------------------------- /public/logo_orig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itcon-pty-au/stremio-ai-search/3ed435f06694a9d6091076e2adfe1de904524b2b/public/logo_orig.png -------------------------------------------------------------------------------- /public/redirect.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | // Suppress punycode deprecation warning 2 | process.removeAllListeners("warning"); 3 | process.on("warning", (warning) => { 4 | if ( 5 | warning.name !== "DeprecationWarning" || 6 | !warning.message.includes("punycode") 7 | ) { 8 | console.warn(warning); 9 | } 10 | }); 11 | 12 | try { 13 | require("dotenv").config(); 14 | } catch (error) { 15 | logger.warn("dotenv module not found, continuing without .env file support"); 16 | } 17 | 18 | const { serveHTTP } = require("stremio-addon-sdk"); 19 | const { addonInterface, catalogHandler } = require("./addon"); 20 | const express = require("express"); 21 | const compression = require("compression"); 22 | const rateLimit = require("express-rate-limit"); 23 | const fs = require("fs"); 24 | const path = require("path"); 25 | const logger = require("./utils/logger"); 26 | const { handleIssueSubmission } = require("./utils/issueHandler"); 27 | const { 28 | encryptConfig, 29 | decryptConfig, 30 | isValidEncryptedFormat, 31 | } = require("./utils/crypto"); 32 | const zlib = require("zlib"); 33 | 34 | // Admin token for cache management 35 | const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "change-me-in-env-file"; 36 | 37 | // Cache persistence configuration 38 | const CACHE_BACKUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour 39 | const CACHE_FOLDER = path.join(__dirname, "cache_data"); 40 | 41 | // Ensure cache folder exists 42 | if (!fs.existsSync(CACHE_FOLDER)) { 43 | fs.mkdirSync(CACHE_FOLDER, { recursive: true }); 44 | } 45 | 46 | // Function to validate admin token 47 | const validateAdminToken = (req, res, next) => { 48 | const token = req.query.adminToken; 49 | 50 | if (!token || token !== ADMIN_TOKEN) { 51 | return res 52 | .status(403) 53 | .json({ error: "Unauthorized. Invalid admin token." }); 54 | } 55 | 56 | next(); 57 | }; 58 | 59 | // Function to save all caches to files 60 | async function saveCachesToFiles() { 61 | try { 62 | const { serializeAllCaches } = require("./addon"); 63 | const allCaches = serializeAllCaches(); 64 | 65 | // Create an array to store promises for all file write operations 66 | const savePromises = []; 67 | const results = {}; 68 | 69 | // Save each cache to its own file 70 | for (const [cacheName, cacheData] of Object.entries(allCaches)) { 71 | const cacheFilePath = path.join(CACHE_FOLDER, `${cacheName}.json.gz`); 72 | 73 | // Add the promise to the array 74 | savePromises.push( 75 | new Promise((resolve, reject) => { 76 | try { 77 | // Convert to JSON without pretty printing 78 | const jsonData = JSON.stringify(cacheData); 79 | 80 | // Compress the data 81 | const compressed = zlib.gzipSync(jsonData); 82 | 83 | // Write the compressed data to file 84 | fs.promises 85 | .writeFile(cacheFilePath, compressed) 86 | .then(() => { 87 | // Check if this is a cache object with entries or the stats object 88 | if (cacheName === "stats") { 89 | results[cacheName] = { 90 | success: true, 91 | originalSize: jsonData.length, 92 | compressedSize: compressed.length, 93 | compressionRatio: 94 | ((compressed.length / jsonData.length) * 100).toFixed(2) + 95 | "%", 96 | path: cacheFilePath, 97 | }; 98 | } else { 99 | results[cacheName] = { 100 | success: true, 101 | size: cacheData.entries ? cacheData.entries.length : 0, 102 | originalSize: jsonData.length, 103 | compressedSize: compressed.length, 104 | compressionRatio: 105 | ((compressed.length / jsonData.length) * 100).toFixed(2) + 106 | "%", 107 | path: cacheFilePath, 108 | }; 109 | } 110 | resolve(); 111 | }) 112 | .catch((err) => { 113 | logger.error(`Error saving ${cacheName} to file`, { 114 | error: err.message, 115 | stack: err.stack, 116 | }); 117 | results[cacheName] = { 118 | success: false, 119 | error: err.message, 120 | }; 121 | resolve(); // Resolve anyway to continue with other caches 122 | }); 123 | } catch (err) { 124 | logger.error(`Error compressing ${cacheName}`, { 125 | error: err.message, 126 | stack: err.stack, 127 | }); 128 | results[cacheName] = { 129 | success: false, 130 | error: err.message, 131 | }; 132 | resolve(); // Resolve anyway to continue with other caches 133 | } 134 | }) 135 | ); 136 | } 137 | 138 | // Wait for all files to be written 139 | await Promise.all(savePromises); 140 | 141 | logger.info("Cache data saved to individual compressed files", { 142 | timestamp: new Date().toISOString(), 143 | cacheFolder: CACHE_FOLDER, 144 | results, 145 | }); 146 | 147 | return { 148 | success: true, 149 | timestamp: new Date().toISOString(), 150 | cacheFolder: CACHE_FOLDER, 151 | results, 152 | }; 153 | } catch (error) { 154 | logger.error("Error saving cache data to files", { 155 | error: error.message, 156 | stack: error.stack, 157 | }); 158 | 159 | return { 160 | success: false, 161 | error: error.message, 162 | }; 163 | } 164 | } 165 | 166 | // Function to load caches from files 167 | async function loadCachesFromFiles() { 168 | try { 169 | // Check if cache folder exists 170 | if (!fs.existsSync(CACHE_FOLDER)) { 171 | logger.info("No cache folder found, starting with empty caches", { 172 | cacheFolder: CACHE_FOLDER, 173 | }); 174 | return { 175 | success: false, 176 | reason: "No cache folder found", 177 | }; 178 | } 179 | 180 | // Get all cache files (both compressed and uncompressed for backward compatibility) 181 | const files = fs 182 | .readdirSync(CACHE_FOLDER) 183 | .filter((file) => file.endsWith(".json.gz") || file.endsWith(".json")); 184 | 185 | if (files.length === 0) { 186 | logger.info("No cache files found, starting with empty caches", { 187 | cacheFolder: CACHE_FOLDER, 188 | }); 189 | return { 190 | success: false, 191 | reason: "No cache files found", 192 | }; 193 | } 194 | 195 | // Create an object to hold all cache data 196 | const allCacheData = {}; 197 | const results = {}; 198 | 199 | // Read each cache file 200 | for (const file of files) { 201 | try { 202 | const isCompressed = file.endsWith(".json.gz"); 203 | const cacheName = path.basename( 204 | file, 205 | isCompressed ? ".json.gz" : ".json" 206 | ); 207 | const cacheFilePath = path.join(CACHE_FOLDER, file); 208 | 209 | // Read the file 210 | const fileData = await fs.promises.readFile(cacheFilePath); 211 | 212 | let cacheDataJson; 213 | if (isCompressed) { 214 | // Decompress the data 215 | cacheDataJson = zlib.gunzipSync(fileData).toString(); 216 | } else { 217 | // Handle uncompressed files for backward compatibility 218 | cacheDataJson = fileData.toString("utf8"); 219 | } 220 | 221 | const cacheData = JSON.parse(cacheDataJson); 222 | 223 | allCacheData[cacheName] = cacheData; 224 | results[cacheName] = { 225 | success: true, 226 | entriesCount: 227 | cacheName === "stats" ? "N/A" : cacheData.entries?.length || 0, 228 | compressed: isCompressed, 229 | path: cacheFilePath, 230 | }; 231 | } catch (err) { 232 | logger.error(`Error reading cache file ${file}`, { 233 | error: err.message, 234 | stack: err.stack, 235 | }); 236 | results[file] = { 237 | success: false, 238 | error: err.message, 239 | }; 240 | // Continue with other files even if one fails 241 | continue; 242 | } 243 | } 244 | 245 | // Deserialize the caches 246 | const { deserializeAllCaches } = require("./addon"); 247 | const deserializeResults = deserializeAllCaches(allCacheData); 248 | 249 | // Combine results 250 | for (const [cacheName, result] of Object.entries(deserializeResults)) { 251 | if (results[cacheName]) { 252 | results[cacheName].deserialized = result; 253 | } 254 | } 255 | 256 | logger.info("Cache data loaded from individual files", { 257 | timestamp: new Date().toISOString(), 258 | results, 259 | }); 260 | 261 | return { 262 | success: true, 263 | results, 264 | }; 265 | } catch (error) { 266 | logger.error("Error loading cache data from files", { 267 | error: error.message, 268 | stack: error.stack, 269 | }); 270 | 271 | return { 272 | success: false, 273 | error: error.message, 274 | }; 275 | } 276 | } 277 | 278 | const ENABLE_LOGGING = process.env.ENABLE_LOGGING === "true" || false; 279 | 280 | if (ENABLE_LOGGING) { 281 | logger.info("Logging enabled via ENABLE_LOGGING environment variable"); 282 | } 283 | 284 | const PORT = 7000; 285 | const HOST = "https://stremio.itcon.au"; 286 | const BASE_PATH = "/aisearch"; 287 | 288 | const DEFAULT_RPDB_KEY = process.env.RPDB_API_KEY; 289 | const TRAKT_CLIENT_ID = process.env.TRAKT_CLIENT_ID; 290 | const TRAKT_CLIENT_SECRET = process.env.TRAKT_CLIENT_SECRET; 291 | 292 | const setupManifest = { 293 | id: "au.itcon.aisearch", 294 | version: "1.0.0", 295 | name: "AI Search", 296 | description: "AI-powered movie and series recommendations", 297 | logo: `${HOST}${BASE_PATH}/logo.png`, 298 | background: `${HOST}${BASE_PATH}/bg.jpg`, 299 | resources: ["catalog"], 300 | types: ["movie", "series"], 301 | catalogs: [], 302 | behaviorHints: { 303 | configurable: true, 304 | configurationRequired: true, 305 | }, 306 | configurationURL: `${HOST}${BASE_PATH}/configure`, 307 | }; 308 | 309 | const getConfiguredManifest = (geminiKey, tmdbKey) => ({ 310 | ...setupManifest, 311 | behaviorHints: { 312 | configurable: false, 313 | }, 314 | catalogs: [ 315 | { 316 | type: "movie", 317 | id: "top", 318 | name: "AI Movie Search", 319 | extra: [{ name: "search", isRequired: true }], 320 | isSearch: true, 321 | }, 322 | { 323 | type: "series", 324 | id: "top", 325 | name: "AI Series Search", 326 | extra: [{ name: "search", isRequired: true }], 327 | isSearch: true, 328 | }, 329 | ], 330 | }); 331 | 332 | async function startServer() { 333 | try { 334 | // Load caches from files on startup 335 | await loadCachesFromFiles(); 336 | 337 | // Set up periodic cache saving 338 | setInterval(async () => { 339 | await saveCachesToFiles(); 340 | }, CACHE_BACKUP_INTERVAL_MS); 341 | 342 | // Set up graceful shutdown handlers 343 | const gracefulShutdown = async (signal) => { 344 | logger.info(`Received ${signal}. Starting graceful shutdown...`); 345 | 346 | try { 347 | logger.info("Saving all caches and stats before shutdown..."); 348 | const result = await saveCachesToFiles(); 349 | logger.info("Cache save completed", { result }); 350 | } catch (error) { 351 | logger.error("Error saving caches during shutdown", { 352 | error: error.message, 353 | stack: error.stack, 354 | }); 355 | } 356 | 357 | logger.info("Graceful shutdown completed. Exiting process."); 358 | process.exit(0); 359 | }; 360 | 361 | // Register shutdown handlers for different signals 362 | process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); 363 | process.on("SIGINT", () => gracefulShutdown("SIGINT")); 364 | process.on("SIGHUP", () => gracefulShutdown("SIGHUP")); 365 | 366 | if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length < 32) { 367 | logger.error( 368 | "CRITICAL ERROR: ENCRYPTION_KEY environment variable is missing or too short!" 369 | ); 370 | logger.error("The ENCRYPTION_KEY must be at least 32 characters long."); 371 | logger.error( 372 | "Please set this environment variable before starting the server." 373 | ); 374 | process.exit(1); 375 | } 376 | 377 | const app = express(); 378 | app.use(require("express").json({ limit: "10mb" })); 379 | app.use( 380 | compression({ 381 | level: 6, 382 | threshold: 1024, 383 | }) 384 | ); 385 | 386 | app.use((req, res, next) => { 387 | const host = req.hostname; 388 | 389 | if (host === "stremio-dev.itcon.au") { 390 | const path = req.originalUrl || req.url; 391 | 392 | const redirectUrl = `https://stremio.itcon.au${path}`; 393 | 394 | if (ENABLE_LOGGING) { 395 | logger.info("Redirecting from dev to production", { 396 | from: `https://${host}${path}`, 397 | to: redirectUrl, 398 | }); 399 | } 400 | 401 | return res.redirect(301, redirectUrl); 402 | } 403 | 404 | next(); 405 | }); 406 | 407 | app.use("/aisearch", express.static(path.join(__dirname, "public"))); 408 | app.use("/", express.static(path.join(__dirname, "public"))); 409 | 410 | if (ENABLE_LOGGING) { 411 | logger.debug("Static file paths:", { 412 | publicDir: path.join(__dirname, "public"), 413 | baseUrl: HOST, 414 | logoUrl: `${HOST}${BASE_PATH}/logo.png`, 415 | bgUrl: `${HOST}${BASE_PATH}/bg.jpg`, 416 | }); 417 | } 418 | 419 | app.use((req, res, next) => { 420 | if (ENABLE_LOGGING) { 421 | logger.info("Incoming request", { 422 | method: req.method, 423 | path: req.path, 424 | originalUrl: req.originalUrl || req.url, 425 | query: req.query, 426 | params: req.params, 427 | headers: req.headers, 428 | timestamp: new Date().toISOString(), 429 | }); 430 | } 431 | next(); 432 | }); 433 | 434 | app.use((req, res, next) => { 435 | const host = req.hostname; 436 | 437 | if (host === "stremio-dev.itcon.au") { 438 | const path = req.originalUrl || req.url; 439 | const redirectUrl = `https://stremio.itcon.au${path}`; 440 | 441 | if (ENABLE_LOGGING) { 442 | logger.info("Redirecting from dev to production", { 443 | from: `https://${host}${path}`, 444 | to: redirectUrl, 445 | }); 446 | } 447 | 448 | return res.redirect(301, redirectUrl); 449 | } 450 | 451 | const userAgent = req.headers["user-agent"] || ""; 452 | const platform = req.headers["stremio-platform"] || ""; 453 | 454 | let detectedPlatform = "unknown"; 455 | if ( 456 | platform.toLowerCase() === "android-tv" || 457 | userAgent.toLowerCase().includes("android tv") || 458 | userAgent.toLowerCase().includes("chromecast") || 459 | userAgent.toLowerCase().includes("androidtv") 460 | ) { 461 | detectedPlatform = "android-tv"; 462 | } else if ( 463 | !userAgent.toLowerCase().includes("stremio/") && 464 | (userAgent.toLowerCase().includes("android") || 465 | userAgent.toLowerCase().includes("mobile") || 466 | userAgent.toLowerCase().includes("phone")) 467 | ) { 468 | detectedPlatform = "mobile"; 469 | } else if ( 470 | userAgent.toLowerCase().includes("windows") || 471 | userAgent.toLowerCase().includes("macintosh") || 472 | userAgent.toLowerCase().includes("linux") || 473 | userAgent.toLowerCase().includes("stremio/") 474 | ) { 475 | detectedPlatform = "desktop"; 476 | } 477 | 478 | req.stremioInfo = { 479 | platform: detectedPlatform, 480 | userAgent: userAgent, 481 | originalPlatform: platform, 482 | }; 483 | 484 | req.headers["stremio-platform"] = detectedPlatform; 485 | req.headers["stremio-user-agent"] = userAgent; 486 | res.header("Access-Control-Allow-Origin", "*"); 487 | res.header("Access-Control-Allow-Headers", "*"); 488 | res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); 489 | res.header("Cache-Control", "no-cache"); 490 | 491 | if (ENABLE_LOGGING) { 492 | logger.debug("Platform info", { 493 | platform: req.stremioInfo?.platform, 494 | userAgent: req.stremioInfo?.userAgent, 495 | originalPlatform: req.stremioInfo?.originalPlatform, 496 | }); 497 | } 498 | 499 | next(); 500 | }); 501 | 502 | const addonRouter = require("express").Router(); 503 | const routeHandlers = { 504 | manifest: (req, res, next) => { 505 | next(); 506 | }, 507 | catalog: (req, res, next) => { 508 | const searchParam = req.params.extra?.split("search=")[1]; 509 | const searchQuery = searchParam 510 | ? decodeURIComponent(searchParam) 511 | : req.query.search || ""; 512 | next(); 513 | }, 514 | ping: (req, res) => { 515 | res.json({ 516 | status: "ok", 517 | timestamp: new Date().toISOString(), 518 | platform: req.stremioInfo?.platform || "unknown", 519 | path: req.path, 520 | }); 521 | }, 522 | }; 523 | 524 | ["/"].forEach((routePath) => { 525 | addonRouter.get(routePath + "manifest.json", (req, res) => { 526 | const baseManifest = { 527 | ...setupManifest, 528 | behaviorHints: { 529 | ...setupManifest.behaviorHints, 530 | configurationRequired: true, 531 | }, 532 | }; 533 | res.json(baseManifest); 534 | }); 535 | 536 | addonRouter.get(routePath + ":config/manifest.json", (req, res) => { 537 | try { 538 | const encryptedConfig = req.params.config; 539 | 540 | req.stremioConfig = encryptedConfig; 541 | 542 | const manifestWithConfig = { 543 | ...addonInterface.manifest, 544 | behaviorHints: { 545 | ...addonInterface.manifest.behaviorHints, 546 | configurationRequired: !encryptedConfig, 547 | }, 548 | }; 549 | 550 | res.setHeader("Access-Control-Allow-Origin", "*"); 551 | res.setHeader("Content-Type", "application/json"); 552 | res.send(JSON.stringify(manifestWithConfig)); 553 | } catch (error) { 554 | if (ENABLE_LOGGING) { 555 | logger.error("Manifest error:", error); 556 | } 557 | res.status(500).send({ error: "Failed to serve manifest" }); 558 | } 559 | }); 560 | 561 | addonRouter.get( 562 | routePath + ":config/catalog/:type/:id/:extra?.json", 563 | (req, res, next) => { 564 | try { 565 | if (ENABLE_LOGGING) { 566 | logger.debug("Received catalog request", { 567 | type: req.params.type, 568 | id: req.params.id, 569 | extra: req.params.extra, 570 | query: req.query, 571 | }); 572 | } 573 | 574 | const configParam = req.params.config; 575 | 576 | if (configParam && !isValidEncryptedFormat(configParam)) { 577 | if (ENABLE_LOGGING) { 578 | logger.error("Invalid encrypted config format", { 579 | configLength: configParam.length, 580 | configSample: configParam.substring(0, 20) + "...", 581 | }); 582 | } 583 | return res.json({ 584 | metas: [], 585 | error: "Invalid configuration format", 586 | }); 587 | } 588 | 589 | req.stremioConfig = configParam; 590 | 591 | res.setHeader("Access-Control-Allow-Origin", "*"); 592 | res.setHeader("Content-Type", "application/json"); 593 | 594 | const { getRouter } = require("stremio-addon-sdk"); 595 | const sdkRouter = getRouter(addonInterface); 596 | 597 | sdkRouter(req, res, (err) => { 598 | if (err) { 599 | if (ENABLE_LOGGING) { 600 | logger.error("SDK router error:", { error: err }); 601 | } 602 | return res.json({ metas: [] }); 603 | } 604 | 605 | const searchParam = req.params.extra?.split("search=")[1]; 606 | const searchQuery = searchParam 607 | ? decodeURIComponent(searchParam) 608 | : req.query.search || ""; 609 | 610 | if (ENABLE_LOGGING) { 611 | logger.debug("Processing search query", { searchQuery }); 612 | } 613 | 614 | const args = { 615 | type: req.params.type, 616 | id: req.params.id, 617 | extra: req.params.extra, 618 | config: configParam, 619 | search: searchQuery, 620 | }; 621 | 622 | catalogHandler(args, req) 623 | .then((response) => { 624 | const transformedMetas = (response.metas || []).map( 625 | (meta) => ({ 626 | ...meta, 627 | releaseInfo: meta.year?.toString() || "", 628 | genres: (meta.genres || []).map((g) => g.toLowerCase()), 629 | trailers: [], 630 | }) 631 | ); 632 | 633 | if (ENABLE_LOGGING) { 634 | logger.debug("Catalog handler response", { 635 | metasCount: transformedMetas.length, 636 | }); 637 | } 638 | 639 | res.json({ 640 | metas: transformedMetas, 641 | cacheAge: response.cacheAge || 3600, 642 | staleAge: response.staleAge || 7200, 643 | }); 644 | }) 645 | .catch((error) => { 646 | if (ENABLE_LOGGING) { 647 | logger.error("Catalog handler error:", { 648 | error: error.message, 649 | stack: error.stack, 650 | }); 651 | } 652 | res.json({ metas: [] }); 653 | }); 654 | }); 655 | } catch (error) { 656 | if (ENABLE_LOGGING) { 657 | logger.error("Catalog route error:", { 658 | error: error.message, 659 | stack: error.stack, 660 | }); 661 | } 662 | res.json({ metas: [] }); 663 | } 664 | } 665 | ); 666 | 667 | addonRouter.get(routePath + "ping", routeHandlers.ping); 668 | addonRouter.get(routePath + "configure", (req, res) => { 669 | const configurePath = path.join(__dirname, "public", "configure.html"); 670 | 671 | if (!fs.existsSync(configurePath)) { 672 | return res.status(404).send("Configuration page not found"); 673 | } 674 | 675 | // Read the configure.html file 676 | fs.readFile(configurePath, "utf8", (err, data) => { 677 | if (err) { 678 | return res.status(500).send("Error loading configuration page"); 679 | } 680 | 681 | // Replace the placeholder with actual Trakt client ID 682 | const modifiedHtml = data.replace( 683 | 'const TRAKT_CLIENT_ID = "YOUR_ADDON_CLIENT_ID";', 684 | `const TRAKT_CLIENT_ID = "${TRAKT_CLIENT_ID}";` 685 | ); 686 | 687 | // Send the modified HTML 688 | res.send(modifiedHtml); 689 | }); 690 | }); 691 | 692 | // Add Trakt.tv OAuth callback endpoint 693 | addonRouter.get(routePath + "oauth/callback", async (req, res) => { 694 | try { 695 | const { code, state } = req.query; 696 | 697 | if (!code) { 698 | return res.status(400).send(` 699 | 700 | 701 |

Authentication Failed

702 |

No authorization code received from Trakt.tv

703 | 706 | 707 | 708 | `); 709 | } 710 | 711 | // Exchange the code for an access token 712 | const tokenResponse = await fetch( 713 | "https://api.trakt.tv/oauth/token", 714 | { 715 | method: "POST", 716 | headers: { 717 | "Content-Type": "application/json", 718 | }, 719 | body: JSON.stringify({ 720 | code, 721 | client_id: TRAKT_CLIENT_ID, 722 | client_secret: TRAKT_CLIENT_SECRET, 723 | redirect_uri: 724 | "https://stremio.itcon.au/aisearch/oauth/callback", 725 | grant_type: "authorization_code", 726 | }), 727 | } 728 | ); 729 | 730 | if (!tokenResponse.ok) { 731 | throw new Error("Failed to exchange code for token"); 732 | } 733 | 734 | const tokenData = await tokenResponse.json(); 735 | 736 | // Send the token data back to the parent window 737 | res.send(` 738 | 739 | 740 |

Authentication Successful

741 |

You can close this window now.

742 | 753 | 754 | 755 | `); 756 | } catch (error) { 757 | logger.error("OAuth callback error:", { 758 | error: error.message, 759 | stack: error.stack, 760 | }); 761 | res.status(500).send("Error during OAuth callback"); 762 | } 763 | }); 764 | 765 | // Handle configuration editing with encrypted config 766 | addonRouter.get(routePath + ":encryptedConfig/configure", (req, res) => { 767 | const { encryptedConfig } = req.params; 768 | 769 | if (!encryptedConfig || !isValidEncryptedFormat(encryptedConfig)) { 770 | return res.status(400).send("Invalid configuration format"); 771 | } 772 | 773 | const configurePath = path.join(__dirname, "public", "configure.html"); 774 | if (!fs.existsSync(configurePath)) { 775 | return res.status(404).send("Configuration page not found"); 776 | } 777 | 778 | // Read the configure.html file 779 | fs.readFile(configurePath, "utf8", (err, data) => { 780 | if (err) { 781 | return res.status(500).send("Error loading configuration page"); 782 | } 783 | 784 | // Replace the placeholder with actual Trakt client ID and fix image paths 785 | let modifiedHtml = data 786 | .replace( 787 | 'const TRAKT_CLIENT_ID = "YOUR_ADDON_CLIENT_ID";', 788 | `const TRAKT_CLIENT_ID = "${TRAKT_CLIENT_ID}";` 789 | ) 790 | .replace('src="logo.png"', `src="${BASE_PATH}/logo.png"`) 791 | .replace('src="bmc.png"', `src="${BASE_PATH}/bmc.png"`); 792 | 793 | // Add the encrypted config ID to the page 794 | modifiedHtml = modifiedHtml.replace( 795 | 'value=""', 796 | `value="${encryptedConfig}"` 797 | ); 798 | 799 | // Send the modified HTML 800 | res.send(modifiedHtml); 801 | }); 802 | }); 803 | 804 | // Update the getConfig endpoint to handle the full path 805 | addonRouter.get(routePath + "api/getConfig/:configId", (req, res) => { 806 | try { 807 | const { configId } = req.params; 808 | 809 | // Remove any path prefix if present 810 | const cleanConfigId = configId.split("/").pop(); 811 | 812 | if (!cleanConfigId || !isValidEncryptedFormat(cleanConfigId)) { 813 | return res 814 | .status(400) 815 | .json({ error: "Invalid configuration format" }); 816 | } 817 | 818 | const decryptedConfig = decryptConfig(cleanConfigId); 819 | if (!decryptedConfig) { 820 | return res 821 | .status(400) 822 | .json({ error: "Failed to decrypt configuration" }); 823 | } 824 | 825 | // Parse and return the configuration 826 | const config = JSON.parse(decryptedConfig); 827 | res.json(config); 828 | } catch (error) { 829 | logger.error("Error getting configuration:", { 830 | error: error.message, 831 | stack: error.stack, 832 | }); 833 | res.status(500).json({ error: "Internal server error" }); 834 | } 835 | }); 836 | 837 | addonRouter.get( 838 | routePath + "cache/stats", 839 | validateAdminToken, 840 | (req, res) => { 841 | const { getCacheStats } = require("./addon"); 842 | res.json(getCacheStats()); 843 | } 844 | ); 845 | 846 | // API endpoint to decrypt configuration 847 | addonRouter.post(routePath + "api/decrypt-config", (req, res) => { 848 | try { 849 | const { encryptedConfig } = req.body; 850 | 851 | if (!encryptedConfig || !isValidEncryptedFormat(encryptedConfig)) { 852 | return res 853 | .status(400) 854 | .json({ error: "Invalid configuration format" }); 855 | } 856 | 857 | const decryptedConfig = decryptConfig(encryptedConfig); 858 | 859 | if (!decryptedConfig) { 860 | return res 861 | .status(400) 862 | .json({ error: "Failed to decrypt configuration" }); 863 | } 864 | 865 | // Parse the decrypted JSON 866 | const config = JSON.parse(decryptedConfig); 867 | 868 | // Return the configuration object 869 | res.json(config); 870 | } catch (error) { 871 | logger.error("Error decrypting configuration:", { 872 | error: error.message, 873 | stack: error.stack, 874 | }); 875 | res.status(500).json({ error: "Internal server error" }); 876 | } 877 | }); 878 | 879 | addonRouter.get( 880 | routePath + "cache/clear/tmdb", 881 | validateAdminToken, 882 | (req, res) => { 883 | const { clearTmdbCache } = require("./addon"); 884 | res.json(clearTmdbCache()); 885 | } 886 | ); 887 | 888 | addonRouter.get( 889 | routePath + "cache/clear/tmdb-details", 890 | validateAdminToken, 891 | (req, res) => { 892 | const { clearTmdbDetailsCache } = require("./addon"); 893 | res.json(clearTmdbDetailsCache()); 894 | } 895 | ); 896 | 897 | addonRouter.get( 898 | routePath + "cache/clear/tmdb-discover", 899 | validateAdminToken, 900 | (req, res) => { 901 | const { clearTmdbDiscoverCache } = require("./addon"); 902 | res.json(clearTmdbDiscoverCache()); 903 | } 904 | ); 905 | 906 | addonRouter.get( 907 | routePath + "cache/clear/ai", 908 | validateAdminToken, 909 | (req, res) => { 910 | const { clearAiCache } = require("./addon"); 911 | res.json(clearAiCache()); 912 | } 913 | ); 914 | 915 | addonRouter.get( 916 | routePath + "cache/clear/ai/keywords", 917 | validateAdminToken, 918 | (req, res) => { 919 | try { 920 | const keywords = req.query.keywords; 921 | if (!keywords || typeof keywords !== "string") { 922 | return res.status(400).json({ 923 | error: "Keywords parameter is required and must be a string", 924 | }); 925 | } 926 | 927 | const { removeAiCacheByKeywords } = require("./addon"); 928 | const result = removeAiCacheByKeywords(keywords); 929 | 930 | if (!result) { 931 | return res 932 | .status(500) 933 | .json({ error: "Failed to remove cache entries" }); 934 | } 935 | 936 | res.json(result); 937 | } catch (error) { 938 | logger.error("Error in cache/clear/ai/keywords endpoint:", { 939 | error: error.message, 940 | stack: error.stack, 941 | keywords: req.query.keywords, 942 | }); 943 | res.status(500).json({ 944 | error: "Internal server error", 945 | message: error.message, 946 | }); 947 | } 948 | } 949 | ); 950 | 951 | addonRouter.get( 952 | routePath + "cache/clear/rpdb", 953 | validateAdminToken, 954 | (req, res) => { 955 | const { clearRpdbCache } = require("./addon"); 956 | res.json(clearRpdbCache()); 957 | } 958 | ); 959 | 960 | addonRouter.get( 961 | routePath + "cache/clear/trakt", 962 | validateAdminToken, 963 | (req, res) => { 964 | const { clearTraktCache } = require("./addon"); 965 | res.json(clearTraktCache()); 966 | } 967 | ); 968 | 969 | addonRouter.get( 970 | routePath + "cache/clear/trakt-raw", 971 | validateAdminToken, 972 | (req, res) => { 973 | const { clearTraktRawDataCache } = require("./addon"); 974 | res.json(clearTraktRawDataCache()); 975 | } 976 | ); 977 | 978 | addonRouter.get( 979 | routePath + "cache/clear/query-analysis", 980 | validateAdminToken, 981 | (req, res) => { 982 | const { clearQueryAnalysisCache } = require("./addon"); 983 | res.json(clearQueryAnalysisCache()); 984 | } 985 | ); 986 | 987 | // Add endpoint to remove a specific TMDB discover cache item 988 | addonRouter.get( 989 | routePath + "cache/remove/tmdb-discover", 990 | validateAdminToken, 991 | (req, res) => { 992 | const { removeTmdbDiscoverCacheItem } = require("./addon"); 993 | const cacheKey = req.query.key; 994 | res.json(removeTmdbDiscoverCacheItem(cacheKey)); 995 | } 996 | ); 997 | 998 | // Add endpoint to list all TMDB discover cache keys 999 | addonRouter.get( 1000 | routePath + "cache/list/tmdb-discover", 1001 | validateAdminToken, 1002 | (req, res) => { 1003 | const { listTmdbDiscoverCacheKeys } = require("./addon"); 1004 | res.json(listTmdbDiscoverCacheKeys()); 1005 | } 1006 | ); 1007 | 1008 | addonRouter.get( 1009 | routePath + "cache/clear/all", 1010 | validateAdminToken, 1011 | (req, res) => { 1012 | const { 1013 | clearTmdbCache, 1014 | clearTmdbDetailsCache, 1015 | clearTmdbDiscoverCache, 1016 | clearAiCache, 1017 | clearRpdbCache, 1018 | clearTraktCache, 1019 | clearTraktRawDataCache, 1020 | clearQueryAnalysisCache, 1021 | } = require("./addon"); 1022 | const tmdbResult = clearTmdbCache(); 1023 | const tmdbDetailsResult = clearTmdbDetailsCache(); 1024 | const tmdbDiscoverResult = clearTmdbDiscoverCache(); 1025 | const aiResult = clearAiCache(); 1026 | const rpdbResult = clearRpdbCache(); 1027 | const traktResult = clearTraktCache(); 1028 | const traktRawResult = clearTraktRawDataCache(); 1029 | const queryAnalysisResult = clearQueryAnalysisCache(); 1030 | res.json({ 1031 | tmdb: tmdbResult, 1032 | tmdbDetails: tmdbDetailsResult, 1033 | tmdbDiscover: tmdbDiscoverResult, 1034 | ai: aiResult, 1035 | rpdb: rpdbResult, 1036 | trakt: traktResult, 1037 | traktRaw: traktRawResult, 1038 | queryAnalysis: queryAnalysisResult, 1039 | }); 1040 | } 1041 | ); 1042 | 1043 | // Add endpoint to manually save caches to files 1044 | addonRouter.get( 1045 | routePath + "cache/save", 1046 | validateAdminToken, 1047 | async (req, res) => { 1048 | const result = await saveCachesToFiles(); 1049 | res.json(result); 1050 | } 1051 | ); 1052 | 1053 | // Add stats endpoint to the addonRouter 1054 | addonRouter.get(routePath + "stats/count", (req, res) => { 1055 | const { getQueryCount } = require("./addon"); 1056 | const count = getQueryCount(); 1057 | 1058 | // Check if the request wants JSON or widget HTML 1059 | const format = req.query.format || "json"; 1060 | 1061 | if (format === "json") { 1062 | res.json({ count }); 1063 | } else if (format === "widget") { 1064 | res.send(` 1065 | 1066 | 1067 | 1068 | 1069 | 1070 | Stremio AI Search Stats 1071 | 1102 | 1103 | 1104 |
1105 |
${count.toLocaleString()}
1106 |
user queries served
1107 |
1108 | 1109 | 1110 | `); 1111 | } else if (format === "badge") { 1112 | // Simple text for embedding in markdown or other places 1113 | res 1114 | .type("text/plain") 1115 | .send(`${count.toLocaleString()} queries served`); 1116 | } else { 1117 | res.status(400).json({ 1118 | error: "Invalid format. Use 'json', 'widget', or 'badge'", 1119 | }); 1120 | } 1121 | }); 1122 | 1123 | // Add an embeddable widget endpoint to the addonRouter 1124 | addonRouter.get(routePath + "stats/widget.js", (req, res) => { 1125 | res.type("application/javascript").send(` 1126 | (function() { 1127 | const widgetContainer = document.createElement('div'); 1128 | widgetContainer.id = 'stremio-ai-search-counter'; 1129 | widgetContainer.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; 1130 | widgetContainer.style.backgroundColor = '#1e1e1e'; 1131 | widgetContainer.style.color = '#ffffff'; 1132 | widgetContainer.style.borderRadius = '8px'; 1133 | widgetContainer.style.padding = '15px 25px'; 1134 | widgetContainer.style.textAlign = 'center'; 1135 | widgetContainer.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.1)'; 1136 | widgetContainer.style.minWidth = '200px'; 1137 | widgetContainer.style.margin = '10px auto'; 1138 | 1139 | // Insert the widget where the script is included 1140 | const currentScript = document.currentScript; 1141 | currentScript.parentNode.insertBefore(widgetContainer, currentScript); 1142 | 1143 | function updateCounter() { 1144 | fetch('${HOST}${BASE_PATH}/stats/count?format=json') 1145 | .then(response => response.json()) 1146 | .then(data => { 1147 | widgetContainer.innerHTML = \` 1148 |
\${data.count.toLocaleString()}
1149 |
user queries served
1150 | \`; 1151 | }) 1152 | .catch(error => { 1153 | widgetContainer.innerHTML = '
Error loading stats
'; 1154 | logger.error('Error fetching stats:', error); 1155 | }); 1156 | } 1157 | 1158 | // Initial update 1159 | updateCounter(); 1160 | 1161 | // Update every 5 minutes 1162 | setInterval(updateCounter, 5 * 60 * 1000); 1163 | })(); 1164 | `); 1165 | }); 1166 | }); 1167 | 1168 | app.use("/", addonRouter); 1169 | app.use(BASE_PATH, addonRouter); 1170 | 1171 | app.post("/encrypt", express.json(), (req, res) => { 1172 | try { 1173 | const configData = req.body; 1174 | if (!configData) { 1175 | return res.status(400).json({ error: "Missing config data" }); 1176 | } 1177 | 1178 | if (!configData.RpdbApiKey) { 1179 | delete configData.RpdbApiKey; 1180 | } 1181 | 1182 | const configStr = JSON.stringify(configData); 1183 | const encryptedConfig = encryptConfig(configStr); 1184 | 1185 | if (!encryptedConfig) { 1186 | return res.status(500).json({ error: "Encryption failed" }); 1187 | } 1188 | 1189 | return res.json({ 1190 | encryptedConfig, 1191 | usingDefaultRpdb: !configData.RpdbApiKey && !!DEFAULT_RPDB_KEY, 1192 | }); 1193 | } catch (error) { 1194 | logger.error("Encryption endpoint error:", { 1195 | error: error.message, 1196 | stack: error.stack, 1197 | }); 1198 | return res.status(500).json({ error: "Server error" }); 1199 | } 1200 | }); 1201 | 1202 | app.post("/decrypt", express.json(), (req, res) => { 1203 | try { 1204 | const { encryptedConfig } = req.body; 1205 | if (!encryptedConfig) { 1206 | return res.status(400).json({ error: "Missing encrypted config" }); 1207 | } 1208 | 1209 | const decryptedConfig = decryptConfig(encryptedConfig); 1210 | if (!decryptedConfig) { 1211 | return res.status(500).json({ error: "Decryption failed" }); 1212 | } 1213 | 1214 | try { 1215 | const configData = JSON.parse(decryptedConfig); 1216 | return res.json({ success: true, config: configData }); 1217 | } catch (error) { 1218 | return res 1219 | .status(500) 1220 | .json({ error: "Invalid JSON in decrypted config" }); 1221 | } 1222 | } catch (error) { 1223 | logger.error("Decryption endpoint error:", { 1224 | error: error.message, 1225 | stack: error.stack, 1226 | }); 1227 | return res.status(500).json({ error: "Server error" }); 1228 | } 1229 | }); 1230 | 1231 | app.use( 1232 | ["/encrypt", "/decrypt", "/aisearch/encrypt", "/aisearch/decrypt"], 1233 | (req, res, next) => { 1234 | res.header("Access-Control-Allow-Origin", "*"); 1235 | res.header( 1236 | "Access-Control-Allow-Headers", 1237 | "Content-Type, Authorization" 1238 | ); 1239 | res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); 1240 | 1241 | if (req.method === "OPTIONS") { 1242 | return res.sendStatus(200); 1243 | } 1244 | 1245 | next(); 1246 | } 1247 | ); 1248 | 1249 | app.use(["/validate", "/aisearch/validate"], (req, res, next) => { 1250 | res.header("Access-Control-Allow-Origin", "*"); 1251 | res.header("Access-Control-Allow-Headers", "Content-Type, Authorization"); 1252 | res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); 1253 | 1254 | if (req.method === "OPTIONS") { 1255 | return res.sendStatus(200); 1256 | } 1257 | 1258 | next(); 1259 | }); 1260 | 1261 | app.post("/aisearch/validate", express.json(), async (req, res) => { 1262 | const startTime = Date.now(); 1263 | try { 1264 | const { GeminiApiKey, TmdbApiKey, GeminiModel, TraktAccessToken } = 1265 | req.body; 1266 | const validationResults = { 1267 | gemini: false, 1268 | tmdb: false, 1269 | trakt: true, 1270 | errors: {}, 1271 | }; 1272 | const modelToUse = GeminiModel || "gemini-2.0-flash"; 1273 | 1274 | if (ENABLE_LOGGING) { 1275 | logger.debug("Validation request received", { 1276 | timestamp: new Date().toISOString(), 1277 | requestId: req.id || Math.random().toString(36).substring(7), 1278 | geminiKeyLength: GeminiApiKey?.length || 0, 1279 | tmdbKeyLength: TmdbApiKey?.length || 0, 1280 | hasTraktConfig: !!TraktAccessToken, 1281 | geminiModel: modelToUse, 1282 | geminiKeyMasked: GeminiApiKey 1283 | ? `${GeminiApiKey.slice(0, 4)}...${GeminiApiKey.slice(-4)}` 1284 | : null, 1285 | tmdbKeyMasked: TmdbApiKey 1286 | ? `${TmdbApiKey.slice(0, 4)}...${TmdbApiKey.slice(-4)}` 1287 | : null, 1288 | }); 1289 | } 1290 | 1291 | // Validate TMDB key 1292 | try { 1293 | const tmdbUrl = `https://api.themoviedb.org/3/authentication/token/new?api_key=${TmdbApiKey}&language=en-US`; 1294 | if (ENABLE_LOGGING) { 1295 | logger.debug("Making TMDB validation request", { 1296 | url: tmdbUrl.replace(TmdbApiKey, "***"), 1297 | method: "GET", 1298 | timestamp: new Date().toISOString(), 1299 | }); 1300 | } 1301 | 1302 | const tmdbStartTime = Date.now(); 1303 | const tmdbResponse = await fetch(tmdbUrl); 1304 | const tmdbData = await tmdbResponse.json(); 1305 | const tmdbDuration = Date.now() - tmdbStartTime; 1306 | 1307 | if (ENABLE_LOGGING) { 1308 | logger.debug("TMDB validation response", { 1309 | status: tmdbResponse.status, 1310 | success: tmdbData.success, 1311 | duration: `${tmdbDuration}ms`, 1312 | payload: { 1313 | ...tmdbData, 1314 | request_token: tmdbData.request_token ? "***" : undefined, // Mask sensitive data 1315 | }, 1316 | headers: { 1317 | contentType: tmdbResponse.headers.get("content-type"), 1318 | server: tmdbResponse.headers.get("server"), 1319 | }, 1320 | }); 1321 | } 1322 | 1323 | validationResults.tmdb = tmdbData.success === true; 1324 | if (!validationResults.tmdb) { 1325 | validationResults.errors.tmdb = "Invalid TMDB API key"; 1326 | } 1327 | } catch (error) { 1328 | if (ENABLE_LOGGING) { 1329 | logger.error("TMDB validation error:", { 1330 | error: error.message, 1331 | stack: error.stack, 1332 | timestamp: new Date().toISOString(), 1333 | }); 1334 | } 1335 | validationResults.errors.tmdb = "TMDB API validation failed"; 1336 | } 1337 | 1338 | // Validate Gemini key 1339 | try { 1340 | if (ENABLE_LOGGING) { 1341 | logger.debug("Initializing Gemini validation", { 1342 | timestamp: new Date().toISOString(), 1343 | model: modelToUse, 1344 | }); 1345 | } 1346 | 1347 | const { GoogleGenerativeAI } = require("@google/generative-ai"); 1348 | const genAI = new GoogleGenerativeAI(GeminiApiKey); 1349 | const model = genAI.getGenerativeModel({ model: modelToUse }); 1350 | const prompt = "Test prompt for validation."; 1351 | 1352 | if (ENABLE_LOGGING) { 1353 | logger.debug("Making Gemini validation request", { 1354 | model: modelToUse, 1355 | promptLength: prompt.length, 1356 | prompt: prompt, 1357 | timestamp: new Date().toISOString(), 1358 | }); 1359 | } 1360 | 1361 | const geminiStartTime = Date.now(); 1362 | const result = await model.generateContent(prompt); 1363 | const geminiDuration = Date.now() - geminiStartTime; 1364 | 1365 | if (ENABLE_LOGGING) { 1366 | logger.debug("Gemini raw response", { 1367 | timestamp: new Date().toISOString(), 1368 | response: JSON.stringify(result, null, 2), 1369 | candidates: result.response?.candidates, 1370 | promptFeedback: result.response?.promptFeedback, 1371 | }); 1372 | } 1373 | 1374 | const responseText = 1375 | result.response?.candidates?.[0]?.content?.parts?.[0]?.text || ""; 1376 | 1377 | if (ENABLE_LOGGING) { 1378 | logger.debug("Gemini validation response", { 1379 | hasResponse: !!result, 1380 | responseLength: responseText.length, 1381 | duration: `${geminiDuration}ms`, 1382 | payload: { 1383 | text: responseText, 1384 | finishReason: 1385 | result?.response?.promptFeedback?.blockReason || "completed", 1386 | safetyRatings: result?.response?.candidates?.[0]?.safetyRatings, 1387 | citationMetadata: 1388 | result?.response?.candidates?.[0]?.citationMetadata, 1389 | finishMessage: result?.response?.candidates?.[0]?.finishMessage, 1390 | }, 1391 | status: { 1392 | code: result?.response?.candidates?.[0]?.status?.code, 1393 | message: result?.response?.candidates?.[0]?.status?.message, 1394 | }, 1395 | }); 1396 | } 1397 | 1398 | validationResults.gemini = responseText.length > 0; 1399 | if (!validationResults.gemini) { 1400 | validationResults.errors.gemini = 1401 | "Invalid Gemini API key - No response text received"; 1402 | } 1403 | } catch (error) { 1404 | if (ENABLE_LOGGING) { 1405 | logger.error("Gemini validation error:", { 1406 | error: error.message, 1407 | stack: error.stack, 1408 | timestamp: new Date().toISOString(), 1409 | }); 1410 | } 1411 | validationResults.errors.gemini = `Invalid Gemini API key: ${error.message}`; 1412 | } 1413 | 1414 | // Validate Trakt configuration if provided 1415 | if (TraktAccessToken) { 1416 | try { 1417 | const traktResponse = await fetch(`${TRAKT_API_BASE}/users/me`, { 1418 | headers: { 1419 | "Content-Type": "application/json", 1420 | "trakt-api-version": "2", 1421 | "trakt-api-key": TRAKT_CLIENT_ID, 1422 | Authorization: `Bearer ${TraktAccessToken}`, 1423 | }, 1424 | }); 1425 | 1426 | if (!traktResponse.ok) { 1427 | validationResults.trakt = false; 1428 | validationResults.errors.trakt = "Invalid Trakt.tv access token"; 1429 | } 1430 | } catch (error) { 1431 | validationResults.trakt = false; 1432 | validationResults.errors.trakt = "Trakt.tv API validation failed"; 1433 | } 1434 | } 1435 | 1436 | if (ENABLE_LOGGING) { 1437 | logger.debug("API key validation results:", { 1438 | tmdbValid: validationResults.tmdb, 1439 | geminiValid: validationResults.gemini, 1440 | traktValid: validationResults.trakt, 1441 | errors: validationResults.errors, 1442 | totalDuration: `${Date.now() - startTime}ms`, 1443 | timestamp: new Date().toISOString(), 1444 | }); 1445 | } 1446 | 1447 | res.json(validationResults); 1448 | } catch (error) { 1449 | if (ENABLE_LOGGING) { 1450 | logger.error("Validation endpoint error:", { 1451 | error: error.message, 1452 | stack: error.stack, 1453 | duration: `${Date.now() - startTime}ms`, 1454 | timestamp: new Date().toISOString(), 1455 | }); 1456 | } 1457 | res.status(500).json({ 1458 | error: "Validation failed", 1459 | message: error.message, 1460 | }); 1461 | } 1462 | }); 1463 | 1464 | app.get("/validate", (req, res) => { 1465 | res.send(` 1466 | 1467 | 1468 | API Key Validation 1469 | 1477 | 1478 | 1479 |

API Key Validation

1480 |
1481 | 1482 | 1483 |
1484 |
1485 | 1486 | 1487 |
1488 | 1489 |
1490 | 1491 | 1523 | 1524 | 1525 | `); 1526 | }); 1527 | 1528 | app.get("/aisearch/validate", (req, res) => { 1529 | res.redirect("/validate"); 1530 | }); 1531 | 1532 | app.post("/validate", express.json(), async (req, res) => { 1533 | const startTime = Date.now(); 1534 | try { 1535 | const { GeminiApiKey, TmdbApiKey, GeminiModel, TraktAccessToken } = 1536 | req.body; 1537 | const validationResults = { 1538 | gemini: false, 1539 | tmdb: false, 1540 | trakt: true, 1541 | errors: {}, 1542 | }; 1543 | const modelToUse = GeminiModel || "gemini-2.0-flash"; 1544 | 1545 | if (ENABLE_LOGGING) { 1546 | logger.debug("Validation request received", { 1547 | timestamp: new Date().toISOString(), 1548 | requestId: req.id || Math.random().toString(36).substring(7), 1549 | geminiKeyLength: GeminiApiKey?.length || 0, 1550 | tmdbKeyLength: TmdbApiKey?.length || 0, 1551 | hasTraktConfig: !!TraktAccessToken, 1552 | geminiModel: modelToUse, 1553 | geminiKeyMasked: GeminiApiKey 1554 | ? `${GeminiApiKey.slice(0, 4)}...${GeminiApiKey.slice(-4)}` 1555 | : null, 1556 | tmdbKeyMasked: TmdbApiKey 1557 | ? `${TmdbApiKey.slice(0, 4)}...${TmdbApiKey.slice(-4)}` 1558 | : null, 1559 | }); 1560 | } 1561 | 1562 | // Validate TMDB key 1563 | try { 1564 | const tmdbUrl = `https://api.themoviedb.org/3/authentication/token/new?api_key=${TmdbApiKey}&language=en-US`; 1565 | if (ENABLE_LOGGING) { 1566 | logger.debug("Making TMDB validation request", { 1567 | url: tmdbUrl.replace(TmdbApiKey, "***"), 1568 | method: "GET", 1569 | timestamp: new Date().toISOString(), 1570 | }); 1571 | } 1572 | 1573 | const tmdbStartTime = Date.now(); 1574 | const tmdbResponse = await fetch(tmdbUrl); 1575 | const tmdbData = await tmdbResponse.json(); 1576 | const tmdbDuration = Date.now() - tmdbStartTime; 1577 | 1578 | if (ENABLE_LOGGING) { 1579 | logger.debug("TMDB validation response", { 1580 | status: tmdbResponse.status, 1581 | success: tmdbData.success, 1582 | duration: `${tmdbDuration}ms`, 1583 | payload: { 1584 | ...tmdbData, 1585 | request_token: tmdbData.request_token ? "***" : undefined, // Mask sensitive data 1586 | }, 1587 | headers: { 1588 | contentType: tmdbResponse.headers.get("content-type"), 1589 | server: tmdbResponse.headers.get("server"), 1590 | }, 1591 | }); 1592 | } 1593 | 1594 | validationResults.tmdb = tmdbData.success === true; 1595 | if (!validationResults.tmdb) { 1596 | validationResults.errors.tmdb = "Invalid TMDB API key"; 1597 | } 1598 | } catch (error) { 1599 | if (ENABLE_LOGGING) { 1600 | logger.error("TMDB validation error:", { 1601 | error: error.message, 1602 | stack: error.stack, 1603 | timestamp: new Date().toISOString(), 1604 | }); 1605 | } 1606 | validationResults.errors.tmdb = "TMDB API validation failed"; 1607 | } 1608 | 1609 | // Validate Gemini key 1610 | try { 1611 | if (ENABLE_LOGGING) { 1612 | logger.debug("Initializing Gemini validation", { 1613 | timestamp: new Date().toISOString(), 1614 | model: modelToUse, 1615 | }); 1616 | } 1617 | 1618 | const { GoogleGenerativeAI } = require("@google/generative-ai"); 1619 | const genAI = new GoogleGenerativeAI(GeminiApiKey); 1620 | const model = genAI.getGenerativeModel({ model: modelToUse }); 1621 | const prompt = "Test prompt for validation."; 1622 | 1623 | if (ENABLE_LOGGING) { 1624 | logger.debug("Making Gemini validation request", { 1625 | model: modelToUse, 1626 | promptLength: prompt.length, 1627 | prompt: prompt, 1628 | timestamp: new Date().toISOString(), 1629 | }); 1630 | } 1631 | 1632 | const geminiStartTime = Date.now(); 1633 | const result = await model.generateContent(prompt); 1634 | const geminiDuration = Date.now() - geminiStartTime; 1635 | 1636 | if (ENABLE_LOGGING) { 1637 | logger.debug("Gemini raw response", { 1638 | timestamp: new Date().toISOString(), 1639 | response: JSON.stringify(result, null, 2), 1640 | candidates: result.response?.candidates, 1641 | promptFeedback: result.response?.promptFeedback, 1642 | }); 1643 | } 1644 | 1645 | const responseText = 1646 | result.response?.candidates?.[0]?.content?.parts?.[0]?.text || ""; 1647 | 1648 | if (ENABLE_LOGGING) { 1649 | logger.debug("Gemini validation response", { 1650 | hasResponse: !!result, 1651 | responseLength: responseText.length, 1652 | duration: `${geminiDuration}ms`, 1653 | payload: { 1654 | text: responseText, 1655 | finishReason: 1656 | result?.response?.promptFeedback?.blockReason || "completed", 1657 | safetyRatings: result?.response?.candidates?.[0]?.safetyRatings, 1658 | citationMetadata: 1659 | result?.response?.candidates?.[0]?.citationMetadata, 1660 | finishMessage: result?.response?.candidates?.[0]?.finishMessage, 1661 | }, 1662 | status: { 1663 | code: result?.response?.candidates?.[0]?.status?.code, 1664 | message: result?.response?.candidates?.[0]?.status?.message, 1665 | }, 1666 | }); 1667 | } 1668 | 1669 | validationResults.gemini = responseText.length > 0; 1670 | if (!validationResults.gemini) { 1671 | validationResults.errors.gemini = 1672 | "Invalid Gemini API key - No response text received"; 1673 | } 1674 | } catch (error) { 1675 | validationResults.errors.gemini = `Invalid Gemini API key: ${error.message}`; 1676 | } 1677 | 1678 | // Validate Trakt configuration if provided 1679 | if (TraktAccessToken) { 1680 | try { 1681 | const traktResponse = await fetch(`${TRAKT_API_BASE}/users/me`, { 1682 | headers: { 1683 | "Content-Type": "application/json", 1684 | "trakt-api-version": "2", 1685 | "trakt-api-key": TRAKT_CLIENT_ID, 1686 | Authorization: `Bearer ${TraktAccessToken}`, 1687 | }, 1688 | }); 1689 | 1690 | if (!traktResponse.ok) { 1691 | validationResults.trakt = false; 1692 | validationResults.errors.trakt = "Invalid Trakt.tv access token"; 1693 | } 1694 | } catch (error) { 1695 | validationResults.trakt = false; 1696 | validationResults.errors.trakt = "Trakt.tv API validation failed"; 1697 | } 1698 | } 1699 | 1700 | if (ENABLE_LOGGING) { 1701 | logger.debug("API key validation results:", { 1702 | tmdbValid: validationResults.tmdb, 1703 | geminiValid: validationResults.gemini, 1704 | traktValid: validationResults.trakt, 1705 | errors: validationResults.errors, 1706 | totalDuration: `${Date.now() - startTime}ms`, 1707 | timestamp: new Date().toISOString(), 1708 | }); 1709 | } 1710 | 1711 | res.json(validationResults); 1712 | } catch (error) { 1713 | if (ENABLE_LOGGING) { 1714 | logger.error("Validation endpoint error:", { 1715 | error: error.message, 1716 | stack: error.stack, 1717 | duration: `${Date.now() - startTime}ms`, 1718 | timestamp: new Date().toISOString(), 1719 | }); 1720 | } 1721 | res.status(500).json({ 1722 | error: "Validation failed", 1723 | message: error.message, 1724 | }); 1725 | } 1726 | }); 1727 | 1728 | app.get("/test-crypto", (req, res) => { 1729 | try { 1730 | const testData = JSON.stringify({ 1731 | test: "data", 1732 | timestamp: Date.now(), 1733 | }); 1734 | 1735 | const encrypted = encryptConfig(testData); 1736 | const decrypted = decryptConfig(encrypted); 1737 | 1738 | res.json({ 1739 | original: testData, 1740 | encrypted: encrypted, 1741 | decrypted: decrypted, 1742 | success: testData === decrypted, 1743 | encryptedLength: encrypted ? encrypted.length : 0, 1744 | decryptedLength: decrypted ? decrypted.length : 0, 1745 | }); 1746 | } catch (error) { 1747 | res.status(500).json({ 1748 | error: error.message, 1749 | stack: error.stack, 1750 | }); 1751 | } 1752 | }); 1753 | 1754 | // Update Trakt.tv token refresh endpoint to use pre-configured credentials 1755 | app.post("/aisearch/oauth/refresh", async (req, res) => { 1756 | try { 1757 | const { refresh_token } = req.body; 1758 | 1759 | if (!refresh_token) { 1760 | return res.status(400).json({ error: "Missing refresh token" }); 1761 | } 1762 | 1763 | const response = await fetch("https://api.trakt.tv/oauth/token", { 1764 | method: "POST", 1765 | headers: { 1766 | "Content-Type": "application/json", 1767 | }, 1768 | body: JSON.stringify({ 1769 | refresh_token, 1770 | client_id: TRAKT_CLIENT_ID, 1771 | client_secret: TRAKT_CLIENT_SECRET, 1772 | grant_type: "refresh_token", 1773 | }), 1774 | }); 1775 | 1776 | if (!response.ok) { 1777 | throw new Error("Failed to refresh token"); 1778 | } 1779 | 1780 | const tokenData = await response.json(); 1781 | res.json(tokenData); 1782 | } catch (error) { 1783 | logger.error("Token refresh error:", { 1784 | error: error.message, 1785 | stack: error.stack, 1786 | }); 1787 | res.status(500).json({ error: "Failed to refresh token" }); 1788 | } 1789 | }); 1790 | 1791 | // Add rate limiter for issue submissions 1792 | const issueRateLimiter = rateLimit({ 1793 | windowMs: 60 * 60 * 1000, // 1 hour window 1794 | max: 5, // limit each IP to 5 submissions per window 1795 | message: { 1796 | error: 1797 | "Too many submissions from this IP, please try again after an hour", 1798 | }, 1799 | standardHeaders: true, 1800 | legacyHeaders: false, 1801 | }); 1802 | 1803 | // Add the issue submission endpoint to the addonRouter 1804 | addonRouter.post( 1805 | "/submit-issue", 1806 | issueRateLimiter, 1807 | express.json(), 1808 | async (req, res) => { 1809 | try { 1810 | if (ENABLE_LOGGING) { 1811 | logger.debug("Issue submission received", { 1812 | title: req.body.title, 1813 | feedbackType: req.body.feedbackType, 1814 | email: req.body.email, 1815 | hasRecaptcha: !!req.body.recaptchaToken, 1816 | timestamp: new Date().toISOString(), 1817 | }); 1818 | } 1819 | 1820 | const result = await handleIssueSubmission(req.body); 1821 | res.json(result); 1822 | } catch (error) { 1823 | if (ENABLE_LOGGING) { 1824 | logger.error("Issue submission error:", { 1825 | error: error.message, 1826 | stack: error.stack, 1827 | timestamp: new Date().toISOString(), 1828 | }); 1829 | } 1830 | res.status(400).json({ error: error.message }); 1831 | } 1832 | } 1833 | ); 1834 | 1835 | app.listen(PORT, "0.0.0.0", () => { 1836 | if (ENABLE_LOGGING) { 1837 | logger.info("Server started", { 1838 | environment: "production", 1839 | port: PORT, 1840 | urls: { 1841 | base: HOST, 1842 | manifest: `${HOST}${BASE_PATH}/manifest.json`, 1843 | configure: `${HOST}${BASE_PATH}/configure`, 1844 | }, 1845 | addon: { 1846 | id: setupManifest.id, 1847 | version: setupManifest.version, 1848 | name: setupManifest.name, 1849 | }, 1850 | static: { 1851 | publicDir: path.join(__dirname, "public"), 1852 | logo: setupManifest.logo, 1853 | background: setupManifest.background, 1854 | }, 1855 | }); 1856 | } 1857 | }); 1858 | } catch (error) { 1859 | if (ENABLE_LOGGING) { 1860 | logger.error("Server error:", { 1861 | error: error.message, 1862 | stack: error.stack, 1863 | }); 1864 | } 1865 | process.exit(1); 1866 | } 1867 | } 1868 | 1869 | startServer(); 1870 | -------------------------------------------------------------------------------- /utils/apiRetry.js: -------------------------------------------------------------------------------- 1 | const logger = require("./logger"); 2 | 3 | /** 4 | * Executes an API call with retry logic and exponential backoff 5 | * 6 | * @param {Function} apiCallFn - Async function that makes the API call 7 | * @param {Object} options - Configuration options 8 | * @param {number} options.maxRetries - Maximum number of retry attempts (default: 3) 9 | * @param {number} options.initialDelay - Initial delay in ms before first retry (default: 1000) 10 | * @param {number} options.maxDelay - Maximum delay in ms between retries (default: 10000) 11 | * @param {Function} options.shouldRetry - Function to determine if error is retryable (default: all non-4xx errors) 12 | * @param {string} options.operationName - Name of operation for logging (default: "API call") 13 | * @returns {Promise} - Result of the API call 14 | */ 15 | async function withRetry(apiCallFn, options = {}) { 16 | const { 17 | maxRetries = 3, 18 | initialDelay = 1000, 19 | maxDelay = 10000, 20 | shouldRetry = (error) => { 21 | // By default, retry on network errors and 5xx responses 22 | // Don't retry on 4xx errors (client errors) 23 | return !error.status || error.status >= 500; 24 | }, 25 | operationName = "API call", 26 | } = options; 27 | 28 | let lastError; 29 | let attempt = 0; 30 | 31 | while (attempt <= maxRetries) { 32 | try { 33 | // Attempt the API call 34 | return await apiCallFn(); 35 | } catch (error) { 36 | lastError = error; 37 | attempt++; 38 | 39 | // Check if we should retry 40 | if (attempt > maxRetries || !shouldRetry(error)) { 41 | logger.error(`${operationName} failed after ${attempt} attempts`, { 42 | error: error.message || error, 43 | status: error.status, 44 | operationName, 45 | }); 46 | throw error; 47 | } 48 | 49 | // Calculate delay with exponential backoff and jitter 50 | const delay = Math.min( 51 | maxDelay, 52 | initialDelay * Math.pow(2, attempt - 1) * (0.5 + Math.random() / 2) 53 | ); 54 | 55 | logger.warn( 56 | `${operationName} failed (attempt ${attempt}/${maxRetries}), retrying in ${Math.round( 57 | delay 58 | )}ms`, 59 | { 60 | error: error.message || error, 61 | status: error.status, 62 | attempt, 63 | nextRetryDelay: Math.round(delay), 64 | } 65 | ); 66 | 67 | // Wait before retrying 68 | await new Promise((resolve) => setTimeout(resolve, delay)); 69 | } 70 | } 71 | 72 | // This should never be reached due to the throw in the catch block 73 | throw lastError; 74 | } 75 | 76 | module.exports = { 77 | withRetry, 78 | }; 79 | -------------------------------------------------------------------------------- /utils/crypto.js: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | const logger = require("./logger"); 3 | 4 | // Try to load dotenv, but don't fail if it's not available 5 | try { 6 | require("dotenv").config(); 7 | } catch (error) { 8 | logger.warn("dotenv module not found, continuing without .env file support"); 9 | } 10 | 11 | // Get the encryption key from environment variables with NO fallback 12 | const SECRET_KEY = process.env.ENCRYPTION_KEY; 13 | 14 | // Log a critical error if the key is missing or too short 15 | if (!SECRET_KEY) { 16 | logger.error( 17 | "CRITICAL ERROR: ENCRYPTION_KEY environment variable is missing. Encryption/decryption will fail!" 18 | ); 19 | } else if (SECRET_KEY.length < 32) { 20 | logger.error( 21 | "CRITICAL ERROR: ENCRYPTION_KEY environment variable is too short (must be at least 32 characters). Encryption/decryption will fail!" 22 | ); 23 | } 24 | 25 | // Encryption function 26 | function encryptConfig(configData) { 27 | try { 28 | if (!SECRET_KEY || SECRET_KEY.length < 32) { 29 | throw new Error( 30 | "Invalid encryption key - must be at least 32 characters" 31 | ); 32 | } 33 | 34 | // Generate a random initialization vector 35 | const iv = crypto.randomBytes(16); 36 | 37 | // Create cipher using AES-256-CBC 38 | const cipher = crypto.createCipheriv( 39 | "aes-256-cbc", 40 | Buffer.from(SECRET_KEY.slice(0, 32)), 41 | iv 42 | ); 43 | 44 | // Encrypt the data 45 | let encrypted = cipher.update(configData, "utf8", "base64"); 46 | encrypted += cipher.final("base64"); 47 | 48 | // Return IV and encrypted data as a single base64 string 49 | // Make sure the format is consistent: iv:encrypted 50 | const result = iv.toString("hex") + ":" + encrypted; 51 | 52 | // URL-safe base64 encoding 53 | return Buffer.from(result) 54 | .toString("base64") 55 | .replace(/\+/g, "-") 56 | .replace(/\//g, "_") 57 | .replace(/=+$/, ""); 58 | } catch (error) { 59 | logger.error("Encryption error:", { error: error.message }); 60 | return null; 61 | } 62 | } 63 | 64 | // Decryption function 65 | function decryptConfig(encryptedData) { 66 | try { 67 | if (!SECRET_KEY || SECRET_KEY.length < 32) { 68 | throw new Error( 69 | "Invalid encryption key - must be at least 32 characters" 70 | ); 71 | } 72 | 73 | // Check if encryptedData is a string 74 | if (typeof encryptedData !== "string") { 75 | logger.error("Invalid encrypted data type:", { 76 | type: typeof encryptedData, 77 | }); 78 | return null; 79 | } 80 | 81 | // Add more detailed logging for debugging 82 | logger.debug("Attempting to decrypt data", { 83 | length: encryptedData.length, 84 | }); 85 | 86 | // Restore base64 padding and standard characters 87 | let base64Data = encryptedData.replace(/-/g, "+").replace(/_/g, "/"); 88 | 89 | // Add padding if needed 90 | while (base64Data.length % 4) { 91 | base64Data += "="; 92 | } 93 | 94 | // Decode the base64 string 95 | let buffer; 96 | try { 97 | buffer = Buffer.from(base64Data, "base64").toString("utf8"); 98 | } catch (e) { 99 | logger.error("Base64 decoding error:", { error: e.message }); 100 | return null; 101 | } 102 | 103 | // Check if buffer is valid 104 | if (!buffer || buffer.length === 0) { 105 | logger.error("Empty buffer after base64 decoding"); 106 | return null; 107 | } 108 | 109 | // Split the IV and encrypted data 110 | const parts = buffer.split(":"); 111 | if (parts.length !== 2) { 112 | logger.error( 113 | "Invalid encrypted data format (expected format: 'iv:encrypted')", 114 | { 115 | parts: parts.length, 116 | bufferPreview: buffer.substring(0, 20) + "...", 117 | } 118 | ); 119 | return null; 120 | } 121 | 122 | const iv = Buffer.from(parts[0], "hex"); 123 | if (iv.length !== 16) { 124 | logger.error("Invalid IV length", { length: iv.length, expected: 16 }); 125 | return null; 126 | } 127 | 128 | const encrypted = parts[1]; 129 | if (!encrypted || encrypted.length === 0) { 130 | logger.error("Empty encrypted data part"); 131 | return null; 132 | } 133 | 134 | // Create decipher 135 | const decipher = crypto.createDecipheriv( 136 | "aes-256-cbc", 137 | Buffer.from(SECRET_KEY.slice(0, 32)), 138 | iv 139 | ); 140 | 141 | // Decrypt the data 142 | let decrypted = decipher.update(encrypted, "base64", "utf8"); 143 | decrypted += decipher.final("utf8"); 144 | 145 | return decrypted; 146 | } catch (error) { 147 | logger.error("Decryption error:", { 148 | error: error.message, 149 | dataPreview: encryptedData 150 | ? encryptedData.substring(0, 20) + "..." 151 | : "null", 152 | }); 153 | return null; 154 | } 155 | } 156 | 157 | // Add this function to validate encrypted data format 158 | function isValidEncryptedFormat(encryptedData) { 159 | if (typeof encryptedData !== "string" || encryptedData.length < 10) { 160 | return false; 161 | } 162 | 163 | try { 164 | // Restore base64 padding and standard characters 165 | let base64Data = encryptedData.replace(/-/g, "+").replace(/_/g, "/"); 166 | 167 | // Add padding if needed 168 | while (base64Data.length % 4) { 169 | base64Data += "="; 170 | } 171 | 172 | // Decode the base64 string 173 | const buffer = Buffer.from(base64Data, "base64").toString("utf8"); 174 | 175 | // Check if it has the expected format (iv:encrypted) 176 | return buffer.includes(":"); 177 | } catch (e) { 178 | return false; 179 | } 180 | } 181 | 182 | module.exports = { 183 | encryptConfig, 184 | decryptConfig, 185 | isValidEncryptedFormat, 186 | }; 187 | -------------------------------------------------------------------------------- /utils/issueHandler.js: -------------------------------------------------------------------------------- 1 | const logger = require("./logger"); 2 | 3 | // Verify reCAPTCHA token 4 | async function verifyRecaptcha(token) { 5 | try { 6 | const response = await fetch( 7 | "https://www.google.com/recaptcha/api/siteverify", 8 | { 9 | method: "POST", 10 | headers: { 11 | "Content-Type": "application/x-www-form-urlencoded", 12 | }, 13 | body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`, 14 | } 15 | ); 16 | 17 | const data = await response.json(); 18 | 19 | // For v3, we need to check both success and score 20 | if (!data.success) { 21 | logger.error("reCAPTCHA verification failed:", data["error-codes"]); 22 | return false; 23 | } 24 | 25 | // Check if the score is above our threshold (0.5 is a moderate threshold) 26 | if (data.score < 0.5) { 27 | logger.error("reCAPTCHA score too low:", data.score); 28 | return false; 29 | } 30 | 31 | // Check if the action matches what we expect 32 | if (data.action !== "submit_issue") { 33 | logger.error("reCAPTCHA action mismatch:", data.action); 34 | return false; 35 | } 36 | 37 | return true; 38 | } catch (error) { 39 | logger.error("reCAPTCHA verification error:", error); 40 | return false; 41 | } 42 | } 43 | 44 | // Create GitHub issue 45 | async function createGitHubIssue(data) { 46 | const { 47 | feedbackType, 48 | title, 49 | deviceType, 50 | browserType, 51 | errorDetails, 52 | comments, 53 | } = data; 54 | 55 | // Log the received data 56 | logger.debug("Creating GitHub issue with data:", { 57 | feedbackType, 58 | title, 59 | deviceType, 60 | browserType, 61 | hasErrorDetails: !!errorDetails, 62 | hasComments: !!comments, 63 | }); 64 | 65 | const isIssue = feedbackType === "issue"; 66 | const issueTitle = isIssue 67 | ? `[Bug Report] ${title}` 68 | : `[Feature Request] ${title}`; 69 | 70 | let body = `## ${isIssue ? "Bug Report" : "Feature Request"}\n\n`; 71 | 72 | if (isIssue) { 73 | body += `**Device Type:** ${deviceType}`; 74 | if (deviceType === "web" && browserType) { 75 | body += ` (Browser: ${browserType})`; 76 | } 77 | body += "\n\n"; 78 | if (errorDetails) { 79 | body += `**Error Details:**\n\`\`\`\n${errorDetails}\n\`\`\`\n\n`; 80 | } 81 | } 82 | 83 | body += `**Description:**\n${comments}\n\n`; 84 | body += `---\n*Submitted via Stremio AI Search Addon*`; 85 | 86 | try { 87 | const response = await fetch( 88 | "https://api.github.com/repos/itcon-pty-au/stremio-ai-search/issues", 89 | { 90 | method: "POST", 91 | headers: { 92 | Accept: "application/vnd.github.v3+json", 93 | Authorization: `token ${process.env.GITHUB_TOKEN}`, 94 | "Content-Type": "application/json", 95 | }, 96 | body: JSON.stringify({ 97 | title: issueTitle, 98 | body, 99 | labels: [feedbackType], 100 | }), 101 | } 102 | ); 103 | 104 | if (!response.ok) { 105 | const errorData = await response.json(); 106 | logger.error("GitHub API error response:", errorData); 107 | throw new Error(`GitHub API error: ${response.status}`); 108 | } 109 | 110 | const result = await response.json(); 111 | return result.html_url; 112 | } catch (error) { 113 | logger.error("GitHub issue creation error:", error); 114 | throw new Error("Failed to create GitHub issue"); 115 | } 116 | } 117 | 118 | // Main handler function 119 | async function handleIssueSubmission(data) { 120 | const { recaptchaToken } = data; 121 | 122 | // Verify reCAPTCHA 123 | const isValidCaptcha = await verifyRecaptcha(recaptchaToken); 124 | if (!isValidCaptcha) { 125 | throw new Error("Invalid reCAPTCHA verification"); 126 | } 127 | 128 | // Create GitHub issue 129 | const issueUrl = await createGitHubIssue(data); 130 | 131 | return { success: true }; 132 | } 133 | 134 | module.exports = { 135 | handleIssueSubmission, 136 | }; 137 | -------------------------------------------------------------------------------- /utils/logger.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | // Use environment variable for logging 5 | const ENABLE_LOGGING = process.env.ENABLE_LOGGING === "true" || false; 6 | 7 | // Create logs directory if it doesn't exist (always create it for query logging) 8 | const logsDir = path.join(__dirname, "..", "logs"); 9 | if (!fs.existsSync(logsDir)) { 10 | fs.mkdirSync(logsDir, { recursive: true }); 11 | } 12 | 13 | // Keep track of last query and timestamp to prevent duplicates 14 | let lastQuery = ""; 15 | let lastQueryTime = 0; 16 | const DUPLICATE_WINDOW = 15000; // 15 second window to detect duplicates 17 | 18 | /** 19 | * Helper function to format and write logs 20 | * @param {string} level - Log level (DEBUG, INFO, WARN, ERROR) 21 | * @param {string} message - Log message 22 | * @param {object} data - Optional data to log 23 | */ 24 | function writeLog(level, message, data) { 25 | // Format the log message 26 | const timestamp = new Date().toISOString(); 27 | const formattedData = data ? `\n${JSON.stringify(data, null, 2)}` : ""; 28 | const logMessage = `[${timestamp}] ${level}: ${message}${formattedData}\n`; 29 | 30 | // Write to file 31 | fs.appendFile( 32 | path.join(logsDir, "app.log"), 33 | logMessage, 34 | () => {} // Silent error handling 35 | ); 36 | } 37 | 38 | /** 39 | * Helper function to get Melbourne time with DST correction 40 | * @returns {string} Formatted timestamp 41 | */ 42 | function getMelbourneTime() { 43 | return new Date() 44 | .toLocaleString("en-AU", { 45 | timeZone: "Australia/Melbourne", 46 | year: "numeric", 47 | month: "2-digit", 48 | day: "2-digit", 49 | hour: "2-digit", 50 | minute: "2-digit", 51 | second: "2-digit", 52 | hour12: false, 53 | }) 54 | .replace(/[/]/g, "-") 55 | .replace(",", ""); 56 | } 57 | 58 | /** 59 | * Helper function to log queries independently of ENABLE_LOGGING 60 | * @param {string} query - The search query 61 | */ 62 | function logQuery(query) { 63 | const now = Date.now(); 64 | 65 | // Check if this is a duplicate query within the time window 66 | if (query === lastQuery && now - lastQueryTime < DUPLICATE_WINDOW) { 67 | return; // Skip duplicate query 68 | } 69 | 70 | // Update last query tracking 71 | lastQuery = query; 72 | lastQueryTime = now; 73 | 74 | // Create log line with Melbourne time 75 | const logLine = `${getMelbourneTime()}|${query}\n`; 76 | 77 | // Write to query log file with error handling 78 | fs.appendFile(path.join(logsDir, "query.log"), logLine, (err) => { 79 | if (err) { 80 | console.error("Error writing to query.log:", err); 81 | } 82 | }); 83 | } 84 | 85 | /** 86 | * Helper function to log empty catalog queries to error.log 87 | * @param {string} query - The search query that returned no results 88 | * @param {object} data - Additional data about the query (type, filters, etc.) 89 | */ 90 | function logEmptyCatalog(query, data = {}) { 91 | const timestamp = new Date().toISOString(); 92 | const formattedData = JSON.stringify(data, null, 2); 93 | const logMessage = `[${timestamp}] EMPTY_CATALOG: Query "${query}" returned no results\n${formattedData}\n`; 94 | 95 | // Write to error log file 96 | fs.appendFile( 97 | path.join(logsDir, "error.log"), 98 | logMessage, 99 | () => {} // Silent error handling 100 | ); 101 | } 102 | 103 | // Simplified logger without console logs, only file logging 104 | const logger = { 105 | debug: function (message, data) { 106 | if (ENABLE_LOGGING) { 107 | writeLog("DEBUG", message, data); 108 | } 109 | }, 110 | info: function (message, data) { 111 | if (ENABLE_LOGGING) { 112 | writeLog("INFO", message, data); 113 | } 114 | }, 115 | warn: function (message, data) { 116 | if (ENABLE_LOGGING) { 117 | writeLog("WARN", message, data); 118 | } 119 | }, 120 | error: function (message, data) { 121 | if (ENABLE_LOGGING) { 122 | writeLog("ERROR", message, data); 123 | } 124 | }, 125 | query: logQuery, // Add the query logger to the logger object 126 | emptyCatalog: function (reason, data = {}) { 127 | // Skip logging for specific errors we want to ignore 128 | const skipPatterns = [ 129 | "Invalid IV length", 130 | "punycode", 131 | "DeprecationWarning", 132 | "Missing configuration", 133 | "Invalid configuration", 134 | "Missing API keys", 135 | "Invalid API key", 136 | "Invalid encrypted data format", 137 | "Buffer starts with", 138 | "Got parts", 139 | "Expected format: 'iv:encrypted'", 140 | "No search query provided", 141 | ]; 142 | 143 | // Check if any of the skip patterns match the reason or data.error 144 | const shouldSkip = skipPatterns.some( 145 | (pattern) => 146 | reason.includes(pattern) || (data.error && data.error.includes(pattern)) 147 | ); 148 | 149 | if (shouldSkip) { 150 | return; 151 | } 152 | 153 | // Always log empty catalogs regardless of ENABLE_LOGGING 154 | const timestamp = new Date().toISOString(); 155 | const formattedData = JSON.stringify(data, null, 2); 156 | const logMessage = `[${timestamp}] EMPTY_CATALOG: ${reason}\n${formattedData}\n`; 157 | 158 | // Write to error log file 159 | fs.appendFile( 160 | path.join(logsDir, "error.log"), 161 | logMessage, 162 | () => {} // Silent error handling 163 | ); 164 | }, 165 | ENABLE_LOGGING, 166 | }; 167 | 168 | module.exports = logger; 169 | --------------------------------------------------------------------------------