├── vue ├── dist │ ├── favicon.ico │ ├── apple.webp │ ├── claro.webp │ ├── globo.webp │ ├── hayu.webp │ ├── hbo.webp │ ├── hulu.webp │ ├── mubi.webp │ ├── prime.webp │ ├── skygo.webp │ ├── starz.webp │ ├── viki.webp │ ├── zee5.webp │ ├── disney.webp │ ├── netflix.webp │ ├── nlziet.webp │ ├── peacock.webp │ ├── sonyliv.webp │ ├── stremio.png │ ├── youtube.webp │ ├── canal-plus.webp │ ├── jiohotstar.webp │ ├── magellan.webp │ ├── paramount.webp │ ├── videoland.webp │ ├── crunchyroll.webp │ ├── netflixkids.webp │ ├── skyshowtime.webp │ ├── curiositystream.webp │ ├── discovery-plus.webp │ ├── streaming-catalogs.png │ ├── index.html │ └── assets │ │ └── index.bc2b5f37.css ├── public │ ├── favicon.ico │ ├── hbo.webp │ ├── apple.webp │ ├── claro.webp │ ├── globo.webp │ ├── hayu.webp │ ├── hulu.webp │ ├── mubi.webp │ ├── prime.webp │ ├── skygo.webp │ ├── starz.webp │ ├── viki.webp │ ├── zee5.webp │ ├── disney.webp │ ├── magellan.webp │ ├── movistar.webp │ ├── netflix.webp │ ├── nlziet.webp │ ├── peacock.webp │ ├── sonyliv.webp │ ├── stremio.png │ ├── youtube.webp │ ├── canal-plus.webp │ ├── jiohotstar.webp │ ├── paramount.webp │ ├── videoland.webp │ ├── crunchyroll.webp │ ├── netflixkids.webp │ ├── skyshowtime.webp │ ├── curiositystream.webp │ ├── discovery-plus.webp │ └── streaming-catalogs.png ├── .env.development ├── .vscode │ └── extensions.json ├── .env ├── postcss.config.cjs ├── vite.config.js ├── tailwind.config.cjs ├── src │ ├── main.js │ ├── assets │ │ └── vue.svg │ ├── style.css │ ├── components │ │ ├── VInput.vue │ │ └── VButton.vue │ ├── regions-to-countries.json │ └── App.vue ├── .gitignore ├── README.md ├── package.json └── index.html ├── beamup.json ├── deploy.sh ├── index.js ├── nodemon.json ├── docs ├── justwatch.md ├── netflix.graphql ├── netflix.md └── justwatch.graphql ├── src ├── lib │ └── stremio.js ├── services │ ├── cinemeta.js │ ├── justwatch.js │ └── netflix │ │ ├── fetcher.js │ │ └── resolver.js ├── server │ ├── routes │ │ ├── catalog.js │ │ └── manifest.js │ └── index.js └── utils │ └── cache.js ├── docker-compose.yml ├── .dockerignore ├── Dockerfile.local ├── package.json ├── .gitignore ├── scripts └── netflixTop10.js ├── .cursorrules └── README.md /vue/dist/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vue/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vue/.env.development: -------------------------------------------------------------------------------- 1 | VITE_APP_URL='http://127.0.0.1:7700' 2 | -------------------------------------------------------------------------------- /vue/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /vue/.env: -------------------------------------------------------------------------------- 1 | VITE_APP_URL='https://7a82163c306e-stremio-netflix-catalog-addon.baby-beamup.club' 2 | -------------------------------------------------------------------------------- /beamup.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "stremio-netflix-catalog-addon", 3 | "lastCommit": "9ea730e" 4 | } -------------------------------------------------------------------------------- /vue/dist/apple.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/apple.webp -------------------------------------------------------------------------------- /vue/dist/claro.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/claro.webp -------------------------------------------------------------------------------- /vue/dist/globo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/globo.webp -------------------------------------------------------------------------------- /vue/dist/hayu.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/hayu.webp -------------------------------------------------------------------------------- /vue/dist/hbo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/hbo.webp -------------------------------------------------------------------------------- /vue/dist/hulu.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/hulu.webp -------------------------------------------------------------------------------- /vue/dist/mubi.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/mubi.webp -------------------------------------------------------------------------------- /vue/dist/prime.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/prime.webp -------------------------------------------------------------------------------- /vue/dist/skygo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/skygo.webp -------------------------------------------------------------------------------- /vue/dist/starz.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/starz.webp -------------------------------------------------------------------------------- /vue/dist/viki.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/viki.webp -------------------------------------------------------------------------------- /vue/dist/zee5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/zee5.webp -------------------------------------------------------------------------------- /vue/public/hbo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/hbo.webp -------------------------------------------------------------------------------- /vue/dist/disney.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/disney.webp -------------------------------------------------------------------------------- /vue/dist/netflix.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/netflix.webp -------------------------------------------------------------------------------- /vue/dist/nlziet.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/nlziet.webp -------------------------------------------------------------------------------- /vue/dist/peacock.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/peacock.webp -------------------------------------------------------------------------------- /vue/dist/sonyliv.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/sonyliv.webp -------------------------------------------------------------------------------- /vue/dist/stremio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/stremio.png -------------------------------------------------------------------------------- /vue/dist/youtube.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/youtube.webp -------------------------------------------------------------------------------- /vue/public/apple.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/apple.webp -------------------------------------------------------------------------------- /vue/public/claro.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/claro.webp -------------------------------------------------------------------------------- /vue/public/globo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/globo.webp -------------------------------------------------------------------------------- /vue/public/hayu.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/hayu.webp -------------------------------------------------------------------------------- /vue/public/hulu.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/hulu.webp -------------------------------------------------------------------------------- /vue/public/mubi.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/mubi.webp -------------------------------------------------------------------------------- /vue/public/prime.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/prime.webp -------------------------------------------------------------------------------- /vue/public/skygo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/skygo.webp -------------------------------------------------------------------------------- /vue/public/starz.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/starz.webp -------------------------------------------------------------------------------- /vue/public/viki.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/viki.webp -------------------------------------------------------------------------------- /vue/public/zee5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/zee5.webp -------------------------------------------------------------------------------- /vue/dist/canal-plus.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/canal-plus.webp -------------------------------------------------------------------------------- /vue/dist/jiohotstar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/jiohotstar.webp -------------------------------------------------------------------------------- /vue/dist/magellan.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/magellan.webp -------------------------------------------------------------------------------- /vue/dist/paramount.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/paramount.webp -------------------------------------------------------------------------------- /vue/dist/videoland.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/videoland.webp -------------------------------------------------------------------------------- /vue/public/disney.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/disney.webp -------------------------------------------------------------------------------- /vue/public/magellan.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/magellan.webp -------------------------------------------------------------------------------- /vue/public/movistar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/movistar.webp -------------------------------------------------------------------------------- /vue/public/netflix.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/netflix.webp -------------------------------------------------------------------------------- /vue/public/nlziet.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/nlziet.webp -------------------------------------------------------------------------------- /vue/public/peacock.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/peacock.webp -------------------------------------------------------------------------------- /vue/public/sonyliv.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/sonyliv.webp -------------------------------------------------------------------------------- /vue/public/stremio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/stremio.png -------------------------------------------------------------------------------- /vue/public/youtube.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/youtube.webp -------------------------------------------------------------------------------- /vue/dist/crunchyroll.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/crunchyroll.webp -------------------------------------------------------------------------------- /vue/dist/netflixkids.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/netflixkids.webp -------------------------------------------------------------------------------- /vue/dist/skyshowtime.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/skyshowtime.webp -------------------------------------------------------------------------------- /vue/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /vue/public/canal-plus.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/canal-plus.webp -------------------------------------------------------------------------------- /vue/public/jiohotstar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/jiohotstar.webp -------------------------------------------------------------------------------- /vue/public/paramount.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/paramount.webp -------------------------------------------------------------------------------- /vue/public/videoland.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/videoland.webp -------------------------------------------------------------------------------- /vue/dist/curiositystream.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/curiositystream.webp -------------------------------------------------------------------------------- /vue/dist/discovery-plus.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/discovery-plus.webp -------------------------------------------------------------------------------- /vue/public/crunchyroll.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/crunchyroll.webp -------------------------------------------------------------------------------- /vue/public/netflixkids.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/netflixkids.webp -------------------------------------------------------------------------------- /vue/public/skyshowtime.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/skyshowtime.webp -------------------------------------------------------------------------------- /vue/dist/streaming-catalogs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/dist/streaming-catalogs.png -------------------------------------------------------------------------------- /vue/public/curiositystream.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/curiositystream.webp -------------------------------------------------------------------------------- /vue/public/discovery-plus.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/discovery-plus.webp -------------------------------------------------------------------------------- /vue/public/streaming-catalogs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rleroi/Stremio-Streaming-Catalogs-Addon/HEAD/vue/public/streaming-catalogs.png -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | cd vue 6 | npm run build 7 | cd ../ 8 | git add --all 9 | git commit -am "Deploy" 10 | git push origin master 11 | git push beamup master 12 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import app from './src/server/index.js'; 2 | 3 | const port = process.env.PORT || 7700; 4 | app.listen(port, () => { 5 | console.log(`http://127.0.0.1:${port}/manifest.json`); 6 | }); 7 | -------------------------------------------------------------------------------- /vue/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [vue()] 7 | }) 8 | -------------------------------------------------------------------------------- /vue/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{vue,js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "cache/**/*", 4 | "node_modules/**/*", 5 | "vue/dist/**/*", 6 | "*.log" 7 | ], 8 | "watch": [ 9 | "*.js", 10 | "scripts/**/*.js", 11 | "vue/src/**/*" 12 | ], 13 | "ext": "js,json,vue" 14 | } 15 | 16 | -------------------------------------------------------------------------------- /vue/src/main.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { createApp } from 'vue' 3 | import Popper from 'vue3-popper' 4 | 5 | import './style.css' 6 | import App from './App.vue' 7 | 8 | axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 9 | 10 | createApp(App) 11 | .component("Popper", Popper) 12 | .mount('#app'); 13 | -------------------------------------------------------------------------------- /vue/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | *.local 12 | 13 | # Editor directories and files 14 | .vscode/* 15 | !.vscode/extensions.json 16 | .idea 17 | .DS_Store 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | -------------------------------------------------------------------------------- /vue/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + Vite 2 | 3 | This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stremio-netflix-catalog-addon", 3 | "version": "1.1.1", 4 | "description": " ", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "start": "node index.js", 10 | "dev": "nodemon index.js" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "nodemon": "^2.0.19" 16 | }, 17 | "dependencies": { 18 | "axios": "^0.27.2", 19 | "cors": "^2.8.5", 20 | "express": "^4.18.1", 21 | "mixpanel": "^0.17.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /vue/src/components/VButton.vue: -------------------------------------------------------------------------------- 1 | 11 | 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | vue/dist/error.log 4 | .idea 5 | 6 | # Environment files 7 | .env.local 8 | .env.production 9 | # Keep pre-configured frontend env files 10 | !vue/.env 11 | !vue/.env.development 12 | 13 | # Logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage/ 27 | 28 | # Dependency directories 29 | node_modules/ 30 | jspm_packages/ 31 | 32 | # Optional npm cache directory 33 | .npm 34 | 35 | # Optional REPL history 36 | .node_repl_history 37 | 38 | # Output of 'npm pack' 39 | *.tgz 40 | 41 | # Yarn Integrity file 42 | .yarn-integrity 43 | 44 | # Cache directory 45 | cache/ 46 | 47 | 48 | -------------------------------------------------------------------------------- /vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Streaming Catalogs 8 | 9 | 10 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /vue/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Streaming Catalogs 8 | 9 | 10 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /scripts/netflixTop10.js: -------------------------------------------------------------------------------- 1 | /** 2 | * CLI wrapper for Netflix Top 10 fetcher 3 | * This script provides a command-line interface to test the Netflix Top 10 API 4 | */ 5 | import { fetchNetflixTop10 } from '../src/services/netflix/fetcher.js'; 6 | import { fileURLToPath } from 'url'; 7 | import path from 'path'; 8 | 9 | async function runFromCLI() { 10 | const [, , countryCodeArg, typeArg] = process.argv; 11 | 12 | if (!countryCodeArg || !typeArg) { 13 | console.error('Usage: node scripts/netflixTop10.js '); 14 | console.error('Example: node scripts/netflixTop10.js NL shows'); 15 | process.exitCode = 1; 16 | return; 17 | } 18 | 19 | try { 20 | const data = await fetchNetflixTop10(countryCodeArg, typeArg, { 21 | allowInsecureTLS: process.env.TUDUM_ALLOW_INSECURE === '1', 22 | }); 23 | console.log(JSON.stringify(data, null, 2)); 24 | } catch (err) { 25 | console.error(`Failed to fetch Netflix Top 10: ${err.message}`); 26 | process.exitCode = 1; 27 | } 28 | } 29 | 30 | const isCLI = (() => { 31 | try { 32 | const __filename = fileURLToPath(import.meta.url); 33 | return path.resolve(process.argv[1] || '') === __filename; 34 | } catch { 35 | return false; 36 | } 37 | })(); 38 | 39 | if (isCLI) { 40 | void runFromCLI(); 41 | } 42 | -------------------------------------------------------------------------------- /src/services/cinemeta.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | /** 4 | * Fetch metadata from Cinemeta API 5 | * @param {string} imdbId - IMDB ID (e.g., "tt1234567") 6 | * @param {string} type - Content type: "MOVIE" or "SHOW" (or "movie"/"series") 7 | * @param {string} fallbackTitle - Title to use if Cinemeta fails 8 | * @returns {Promise} Metadata object or null if fetch fails 9 | */ 10 | export async function fetchCinemetaMeta(imdbId, type, fallbackTitle = null) { 11 | // Normalize type to cinemeta format 12 | const cinemetaType = type === 'MOVIE' || type === 'movie' ? 'movie' : 'series'; 13 | 14 | try { 15 | const cinemetaResponse = await axios.get( 16 | `https://v3-cinemeta.strem.io/meta/${cinemetaType}/${imdbId}.json`, 17 | { timeout: 5000, validateStatus: (status) => status < 500 } // Don't throw on 404 18 | ); 19 | 20 | if (cinemetaResponse.status === 200 && cinemetaResponse.data?.meta) { 21 | return { 22 | ...cinemetaResponse.data.meta, 23 | id: imdbId, 24 | name: cinemetaResponse.data.meta.name || fallbackTitle, 25 | videos: undefined, // Remove videos array 26 | }; 27 | } 28 | } catch (error) { 29 | // If cinemeta fails (network error, timeout, etc), return null 30 | // 404s are handled by validateStatus above 31 | if (error.code !== 'ECONNABORTED' && error.response?.status !== 404) { 32 | console.log(`Cinemeta fetch failed for ${imdbId}:`, error.message); 33 | } 34 | } 35 | 36 | return null; 37 | } 38 | 39 | /** 40 | * Get basic metadata fallback when Cinemeta is unavailable 41 | * @param {string} imdbId - IMDB ID 42 | * @param {string} title - Title 43 | * @param {string} type - Content type: "movie" or "series" 44 | * @returns {object} Basic metadata object 45 | */ 46 | export function getBasicMeta(imdbId, title, type) { 47 | return { 48 | id: imdbId, 49 | name: title, 50 | type: type === 'MOVIE' || type === 'movie' ? 'movie' : 'series', 51 | poster: `https://live.metahub.space/poster/medium/${imdbId}/img`, 52 | posterShape: 'poster', 53 | }; 54 | } 55 | 56 | -------------------------------------------------------------------------------- /src/server/routes/catalog.js: -------------------------------------------------------------------------------- 1 | import { getNetflixTop10Catalog, getNetflixTop10Global } from '../../services/netflix/resolver.js'; 2 | import { replaceRpdbPosters } from '../../lib/stremio.js'; 3 | 4 | /** 5 | * Catalog route handler 6 | */ 7 | export function handleCatalog(req, res, movies, series, mixpanel) { 8 | res.setHeader('Cache-Control', 'max-age=86400,stale-while-revalidate=86400,stale-if-error=86400,public'); 9 | res.setHeader('content-type', 'application/json'); 10 | 11 | // Parse config 12 | const buffer = Buffer(req.params?.configuration || '', 'base64'); 13 | let [selectedProviders, rpdbKey, countryCode, installedAt] = buffer.toString('ascii')?.split(':'); 14 | 15 | // Handle legacy RPDB key format 16 | if (String(rpdbKey || '').startsWith('16')) { 17 | installedAt = rpdbKey; 18 | rpdbKey = null; 19 | } 20 | 21 | mixpanel && mixpanel.track('catalog', { 22 | ip: req.ip, 23 | distinct_id: req.ip.replace(/\.|:/g, 'Z'), 24 | configuration: req.params?.configuration, 25 | selectedProviders, 26 | rpdbKey, 27 | countryCode, 28 | installedAt, 29 | catalog_type: req.params.type, 30 | catalog_id: req.params.id, 31 | catalog_extra: req.params?.extra, 32 | }); 33 | 34 | let id = req.params.id; 35 | 36 | // Legacy addon, netflix-only catalog support 37 | if (id === 'top') { 38 | id = 'nfx'; 39 | } 40 | 41 | // Jio and Hotstar merged - fallback hst to jhs 42 | if (id === 'hst') { 43 | id = 'jhs'; 44 | } 45 | 46 | // Handle Netflix Top 10 catalogs 47 | if (id.startsWith('netflix-top10-')) { 48 | const isGlobal = id === 'netflix-top10-global'; 49 | const countryCode = isGlobal ? null : id.replace('netflix-top10-', ''); 50 | const type = req.params.type === 'movie' ? 'movies' : 'shows'; 51 | 52 | console.log(`Netflix Top 10 request: id=${id}, isGlobal=${isGlobal}, countryCode=${countryCode}, type=${type}`); 53 | 54 | // Use async handler 55 | (async () => { 56 | try { 57 | let metas; 58 | if (isGlobal) { 59 | console.log(`Fetching global Netflix Top 10 (${type})`); 60 | metas = await getNetflixTop10Global(type); 61 | } else { 62 | console.log(`Fetching Netflix Top 10 for country ${countryCode} (${type})`); 63 | metas = await getNetflixTop10Catalog(countryCode, type); 64 | } 65 | console.log(`Returning ${metas.length} metas for ${id}`); 66 | res.send({ metas: replaceRpdbPosters(rpdbKey, metas) }); 67 | } catch (error) { 68 | console.error(`Error fetching Netflix Top 10 catalog ${id}:`, error.message); 69 | if (error.stack) { 70 | console.error(error.stack); 71 | } 72 | // Make sure response hasn't been sent yet 73 | if (!res.headersSent) { 74 | res.send({ metas: [] }); 75 | } 76 | } 77 | })().catch((error) => { 78 | console.error(`Unhandled error in Netflix Top 10 catalog ${id}:`, error.message); 79 | if (error.stack) { 80 | console.error(error.stack); 81 | } 82 | if (!res.headersSent) { 83 | res.send({ metas: [] }); 84 | } 85 | }); 86 | return; 87 | } 88 | 89 | // Handle regular provider catalogs 90 | if (req.params.type === 'movie') { 91 | res.send({ metas: replaceRpdbPosters(rpdbKey, movies[id] || []) }); 92 | return; 93 | } 94 | 95 | if (req.params.type === 'series') { 96 | res.send({ metas: replaceRpdbPosters(rpdbKey, series[id] || []) }); 97 | return; 98 | } 99 | } 100 | 101 | -------------------------------------------------------------------------------- /src/utils/cache.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = path.dirname(__filename); 7 | 8 | const CACHE_DIR = path.join(__dirname, '../../cache'); 9 | 10 | /** 11 | * Ensure cache directory exists 12 | */ 13 | export function ensureCacheDir() { 14 | if (!fs.existsSync(CACHE_DIR)) { 15 | fs.mkdirSync(CACHE_DIR, { recursive: true }); 16 | } 17 | } 18 | 19 | /** 20 | * Load catalog cache from disk 21 | */ 22 | export function loadCatalogCache(refreshInterval = 21600000) { 23 | ensureCacheDir(); 24 | const cacheFile = path.join(CACHE_DIR, 'catalog-cache.json'); 25 | 26 | try { 27 | if (fs.existsSync(cacheFile)) { 28 | const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); 29 | const now = Date.now(); 30 | 31 | // Check if cache is still valid 32 | if (cacheData.timestamp && (now - cacheData.timestamp) < refreshInterval) { 33 | console.log('Loading catalog data from cache...'); 34 | return { 35 | movies: cacheData.movies || {}, 36 | series: cacheData.series || {} 37 | }; 38 | } else { 39 | console.log('Cache expired, will fetch fresh data...'); 40 | } 41 | } 42 | } catch (error) { 43 | console.log('Error loading cache:', error.message); 44 | } 45 | return null; 46 | } 47 | 48 | /** 49 | * Save catalog cache to disk 50 | */ 51 | export function saveCatalogCache(movies, series) { 52 | ensureCacheDir(); 53 | const cacheFile = path.join(CACHE_DIR, 'catalog-cache.json'); 54 | 55 | try { 56 | const cacheData = { 57 | timestamp: Date.now(), 58 | movies, 59 | series 60 | }; 61 | fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2)); 62 | console.log('Catalog data cached successfully'); 63 | } catch (error) { 64 | console.log('Error saving cache:', error.message); 65 | } 66 | } 67 | 68 | /** 69 | * Clear catalog cache 70 | */ 71 | export function clearCatalogCache() { 72 | ensureCacheDir(); 73 | const cacheFile = path.join(CACHE_DIR, 'catalog-cache.json'); 74 | 75 | try { 76 | if (fs.existsSync(cacheFile)) { 77 | fs.unlinkSync(cacheFile); 78 | console.log('Cache cleared successfully'); 79 | } 80 | } catch (error) { 81 | console.log('Error clearing cache:', error.message); 82 | } 83 | } 84 | 85 | /** 86 | * Load resolution cache from disk 87 | */ 88 | export function loadResolutionCache(cacheDurationMs = 7 * 24 * 60 * 60 * 1000) { 89 | ensureCacheDir(); 90 | const cacheFile = path.join(CACHE_DIR, 'netflix-top10-resolved.json'); 91 | 92 | try { 93 | if (fs.existsSync(cacheFile)) { 94 | const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); 95 | const now = Date.now(); 96 | 97 | // Check if cache is still valid 98 | if (cacheData.timestamp && (now - cacheData.timestamp) < cacheDurationMs) { 99 | return cacheData.resolutions || {}; 100 | } 101 | } 102 | } catch (error) { 103 | console.log('Error loading resolution cache:', error.message); 104 | } 105 | return {}; 106 | } 107 | 108 | /** 109 | * Save resolution cache to disk 110 | */ 111 | export function saveResolutionCache(resolutions) { 112 | ensureCacheDir(); 113 | const cacheFile = path.join(CACHE_DIR, 'netflix-top10-resolved.json'); 114 | 115 | try { 116 | const cacheData = { 117 | timestamp: Date.now(), 118 | resolutions, 119 | }; 120 | fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2)); 121 | } catch (error) { 122 | console.log('Error saving resolution cache:', error.message); 123 | } 124 | } 125 | 126 | /** 127 | * Load Netflix Top 10 catalog cache from disk 128 | * Cache duration: 24 hours (daily refresh) 129 | * Returns object with catalogs and timestamp 130 | */ 131 | export function loadNetflixTop10Cache(cacheDurationMs = 24 * 60 * 60 * 1000) { 132 | ensureCacheDir(); 133 | const cacheFile = path.join(CACHE_DIR, 'netflix-top10-catalog.json'); 134 | 135 | try { 136 | if (fs.existsSync(cacheFile)) { 137 | const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); 138 | const now = Date.now(); 139 | 140 | // Check if cache is still valid 141 | if (cacheData.timestamp && (now - cacheData.timestamp) < cacheDurationMs) { 142 | return { 143 | catalogs: cacheData.catalogs || {}, 144 | timestamp: cacheData.timestamp, 145 | }; 146 | } else { 147 | console.log('Netflix Top 10 catalog cache expired, will fetch fresh data'); 148 | } 149 | } 150 | } catch (error) { 151 | console.log('Error loading Netflix Top 10 catalog cache:', error.message); 152 | } 153 | return { 154 | catalogs: {}, 155 | timestamp: null, 156 | }; 157 | } 158 | 159 | /** 160 | * Save Netflix Top 10 catalog cache to disk 161 | */ 162 | export function saveNetflixTop10Cache(catalogs) { 163 | ensureCacheDir(); 164 | const cacheFile = path.join(CACHE_DIR, 'netflix-top10-catalog.json'); 165 | 166 | try { 167 | const cacheData = { 168 | timestamp: Date.now(), 169 | catalogs, 170 | }; 171 | fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2)); 172 | console.log('Netflix Top 10 catalog cache saved successfully'); 173 | } catch (error) { 174 | console.log('Error saving Netflix Top 10 catalog cache:', error.message); 175 | } 176 | } 177 | 178 | -------------------------------------------------------------------------------- /src/services/justwatch.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { fetchCinemetaMeta, getBasicMeta } from './cinemeta.js'; 3 | 4 | const AMOUNT = 100; 5 | const AMOUNT_TO_VERIFY = 24; 6 | const DUPES_CACHE = {}; 7 | const DELETED_CACHE = []; 8 | 9 | export default { 10 | verify: true, 11 | async getLatest(type = 'MOVIE', providers = ['nfx'], country = "GB", language = 'en') { 12 | // todo 13 | }, 14 | async getMetas(type = 'MOVIE', providers = ['nfx'], country = "GB", language = 'en') { 15 | let res = null; 16 | try { 17 | res = await axios.post('https://apis.justwatch.com/graphql', { 18 | "operationName": "GetPopularTitles", 19 | "variables": { 20 | "popularTitlesSortBy": "TRENDING", 21 | "first": AMOUNT, 22 | "platform": "WEB", 23 | "sortRandomSeed": 0, 24 | "popularAfterCursor": "", 25 | "offset": null, 26 | "popularTitlesFilter": { 27 | "ageCertifications": [], 28 | "excludeGenres": [], 29 | "excludeProductionCountries": [], 30 | "genres": [], 31 | "objectTypes": [ 32 | type 33 | ], 34 | "productionCountries": [], 35 | "packages": providers, 36 | "excludeIrrelevantTitles": false, 37 | "presentationTypes": [], 38 | "monetizationTypes": [ 39 | "FLATRATE", 40 | ], 41 | }, 42 | "language": language, 43 | "country": country 44 | }, 45 | "query": "query GetPopularTitles(\n $country: Country!\n $popularTitlesFilter: TitleFilter\n $popularAfterCursor: String\n $popularTitlesSortBy: PopularTitlesSorting! = POPULAR\n $first: Int!\n $language: Language!\n $offset: Int = 0\n $sortRandomSeed: Int! = 0\n $profile: PosterProfile\n $backdropProfile: BackdropProfile\n $format: ImageFormat\n) {\n popularTitles(\n country: $country\n filter: $popularTitlesFilter\n offset: $offset\n after: $popularAfterCursor\n sortBy: $popularTitlesSortBy\n first: $first\n sortRandomSeed: $sortRandomSeed\n ) {\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n __typename\n }\n edges {\n ...PopularTitleGraphql\n __typename\n }\n __typename\n }\n}\n\nfragment PopularTitleGraphql on PopularTitlesEdge {\n cursor\n node {\n id\n objectId\n objectType\n content(country: $country, language: $language) {\n externalIds {\n imdbId\n }\n title\n fullPath\n scoring {\n imdbScore\n __typename\n }\n posterUrl(profile: $profile, format: $format)\n ... on ShowContent {\n backdrops(profile: $backdropProfile, format: $format) {\n backdropUrl\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}" 46 | }); 47 | } catch (e) { 48 | console.error(e.message); 49 | console.log(e.response?.data); 50 | return []; 51 | } 52 | 53 | console.log(providers.join(','), res.data.data.popularTitles.edges.length); 54 | 55 | return (await Promise.all(res.data.data.popularTitles.edges.map(async (item, index) => { 56 | let imdbId = item.node.content.externalIds.imdbId; 57 | 58 | if (!imdbId || DELETED_CACHE.includes(imdbId)) { 59 | if (imdbId) console.log('deleted cache hit'); 60 | 61 | return null; 62 | } 63 | 64 | if (DUPES_CACHE[imdbId]) { 65 | console.log('dupe cache hit'); 66 | imdbId = DUPES_CACHE[imdbId]; 67 | } else if (index < AMOUNT_TO_VERIFY && this.verify) { 68 | try { 69 | await axios.head(`https://www.imdb.com/title/${imdbId}/`, { 70 | maxRedirects: 0, 71 | headers: { 72 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/110.0' 73 | } 74 | }); 75 | } catch (e) { 76 | if (e.response?.status === 308) { 77 | const newImdbId = e.response?.headers?.['location']?.split('/')?.[2]; 78 | console.log('DUPE imdb redirects to', newImdbId); 79 | DUPES_CACHE[imdbId] = newImdbId; 80 | imdbId = newImdbId; 81 | } else if (e.response?.status === 404) { 82 | console.log('imdb does not exist'); 83 | DELETED_CACHE.push(imdbId); 84 | return null; 85 | } else { 86 | console.error('Stop verifying, IMDB error', e.response?.status); 87 | this.verify = false; 88 | } 89 | } 90 | } 91 | 92 | const posterId = item?.node?.content?.posterUrl?.match(/\/poster\/([0-9]+)\//)?.pop(); 93 | let posterUrl; 94 | if (posterId) { 95 | posterUrl = `https://images.justwatch.com/poster/${posterId}/s332/img`; 96 | } else { 97 | posterUrl = `https://live.metahub.space/poster/medium/${imdbId}/img`; 98 | } 99 | 100 | // get better metadata from cinemeta 101 | const cinemetaMeta = await fetchCinemetaMeta(imdbId, type, item.node.content.title); 102 | 103 | if (cinemetaMeta) { 104 | return { 105 | ...cinemetaMeta, 106 | poster: posterUrl, // Use JustWatch poster URL 107 | }; 108 | } 109 | 110 | // Fallback to basic metadata 111 | return { 112 | ...getBasicMeta(imdbId, item.node.content.title, type), 113 | poster: posterUrl, // Use JustWatch poster URL 114 | }; 115 | }))).filter(item => item?.id); 116 | } 117 | } 118 | 119 | -------------------------------------------------------------------------------- /.cursorrules: -------------------------------------------------------------------------------- 1 | # Stremio Streaming Catalogs Addon - Development Rules 2 | 3 | ## Project Overview 4 | This is a Stremio addon that provides streaming catalogs from 20+ streaming services (Netflix, Disney+, HBO Max, etc.). It consists of a Node.js/Express backend and a Vue.js frontend. 5 | 6 | ## Architecture 7 | - **Backend**: Express.js server serving Stremio addon API endpoints 8 | - **Frontend**: Vue.js 3 + Vite + Tailwind CSS web interface 9 | - **Ports**: Backend runs on 7700, Frontend dev server on 5173 10 | - **Build**: Frontend builds to `vue/dist/` which backend serves statically 11 | 12 | ## Key Files 13 | - `index.js` - Main Express server with addon endpoints 14 | - `addon.js` - Core addon logic for fetching streaming catalogs 15 | - `vue/src/App.vue` - Main Vue component with streaming service selection 16 | - `vue/dist/` - Built frontend (generated, not committed to git) 17 | 18 | ## Environment Variables 19 | - Backend: Optional `.env` in root (MIXPANEL_KEY, PORT, REFRESH_INTERVAL, NODE_ENV) 20 | - Frontend: `vue/.env` (production) and `vue/.env.development` (dev) - both included in repo 21 | - Critical: `VITE_APP_URL` in frontend .env files must point to backend URL 22 | 23 | ## Development Workflow 24 | 1. Backend: `npm run dev` (nodemon auto-reload) 25 | 2. Frontend: `cd vue && npm run dev` (Vite hot reload) 26 | 3. Build: `cd vue && npm run build` (creates dist for backend to serve) 27 | 28 | ## Common Issues & Solutions 29 | - **Server crashes on startup**: Usually due to external API rate limits or network issues in `loadNewCatalog()` 30 | - **Frontend not loading**: Check `VITE_APP_URL` in .env files points to correct backend URL 31 | - **Build issues**: Ensure `vue/dist/` exists (run `npm run build` in vue directory) 32 | - **Rate limiting (429 errors)**: May occur during development when making many API calls 33 | 34 | ## API Endpoints 35 | - `/manifest.json` - Stremio addon manifest 36 | - `/:configuration/manifest.json` - Dynamic manifest based on user selection 37 | - `/catalog/:type/:id.json` - Streaming catalog data 38 | - All other routes serve the Vue frontend 39 | 40 | ## External Dependencies 41 | - Fetches catalogs from various streaming service APIs 42 | - Uses Mixpanel for analytics (optional) 43 | - Requires internet connection for catalog loading 44 | 45 | ## Git Considerations 46 | - `vue/dist/` is in .gitignore (build artifacts) 47 | - `.env` files are committed (pre-configured for dev/prod) 48 | - `node_modules/` excluded from both root and vue directories 49 | 50 | ## Performance Notes 51 | - Catalogs refresh every 6 hours by default (configurable via REFRESH_INTERVAL) 52 | - Server caches responses with appropriate headers 53 | - Frontend uses Vite for fast development builds 54 | 55 | ## Deployment 56 | - Backend serves built frontend from `vue/dist/` 57 | - Production: Set NODE_ENV=production, build frontend, start backend 58 | - Environment files already configured for both dev and production 59 | 60 | ## Adding New Streaming Providers 61 | 62 | ### **CRITICAL: Always Use GraphQL API - Never Guess Provider Codes!** 63 | 64 | **IMPORTANT**: Never guess provider codes like 'vki' for Viki. Always use the GraphQL API to find the actual `shortName` from the `clearName`. The provider code is the `shortName` field, not something you can guess. 65 | 66 | **Reference**: See `docs/justwatch.graphql` for the complete GraphQL schema (reverse engineered, so examples may be incomplete). 67 | 68 | **Step 1: Research Provider** 69 | ```bash 70 | # Find provider code using clearName (this is the actual provider code!) 71 | curl -s "https://apis.justwatch.com/graphql" -H "Content-Type: application/json" \ 72 | -d '{"operationName":"Packages","variables":{"platform":"WEB","country":"US"},"query":"query Packages { packages(platform: WEB, country: US) { shortName clearName } }"}' | jq '.data.packages[] | select(.clearName | contains("ProviderName"))' 73 | 74 | # Find provider icon URL 75 | curl -s "https://apis.justwatch.com/graphql" -H "Content-Type: application/json" \ 76 | -d '{"operationName":"GetPackages","variables":{"country":"US","platform":"WEB"},"query":"query GetPackages($country: Country!, $platform: Platform!) { packages(country: $country, platform: $platform) { id icon } }"}' | jq '.data.packages[] | select(.icon | contains("providername")) | .icon' 77 | ``` 78 | 79 | **Step 2: Test Content Availability** 80 | ```bash 81 | # Check if provider has content 82 | curl -s "https://apis.justwatch.com/graphql" -H "Content-Type: application/json" \ 83 | -d '{"operationName":"GetPopularTitles","variables":{"popularTitlesSortBy":"TRENDING","first":1,"platform":"WEB","popularTitlesFilter":{"objectTypes":["MOVIE"],"packages":["PROVIDER_CODE"]},"language":"en","country":"US"},"query":"query GetPopularTitles($country: Country!, $popularTitlesFilter: TitleFilter, $popularAfterCursor: String, $popularTitlesSortBy: PopularTitlesSorting! = POPULAR, $first: Int!, $language: Language!, $offset: Int = 0, $sortRandomSeed: Int! = 0, $profile: PosterProfile, $backdropProfile: BackdropProfile, $format: ImageFormat) { popularTitles(country: $country, filter: $popularTitlesFilter, offset: $offset, after: $popularAfterCursor, sortBy: $popularTitlesSortBy, first: $first, sortRandomSeed: $sortRandomSeed) { totalCount } }"}' | grep -o '"totalCount":[0-9]*' 84 | ``` 85 | 86 | ### **Integration Steps (In Order)** 87 | 88 | **1. Backend (index.js)** 89 | ```javascript 90 | // Add to movies/series objects 91 | 'mbi': [], 92 | 93 | // Add catalog loading 94 | movies.mbi = await addon.getMetas('MOVIE', ['mbi'], 'US'); 95 | series.mbi = await addon.getMetas('SHOW', ['mbi'], 'US'); 96 | 97 | // Add manifest entry 98 | if (selectedProviders.includes('mbi')) { 99 | catalogs.push({ id: 'mbi', type: 'movie', name: 'Mubi' }); 100 | catalogs.push({ id: 'mbi', type: 'series', name: 'Mubi' }); 101 | } 102 | ``` 103 | 104 | **2. Download Icon** 105 | ```bash 106 | curl -s "https://images.justwatch.com/icon/ICON_ID/s100/providername.webp" -o vue/public/providername.webp 107 | ``` 108 | 109 | **3. Frontend (App.vue)** 110 | ```html 111 | 112 | 113 | 115 | 116 | ``` 117 | 118 | ```javascript 119 | // Add to regions object 120 | 'United States': ['nfx', 'mbi', ...], 121 | 'Any': ['nfx', 'mbi', ...], 122 | ``` 123 | 124 | **4. Test Integration** 125 | ```bash 126 | curl -s "http://localhost:7700/catalog/movie/mbi.json" | jq '.metas | length' 127 | curl -s "http://localhost:7700/catalog/series/mbi.json" | jq '.metas | length' 128 | ``` 129 | 130 | ### **Common Provider Codes** 131 | - nfx: Netflix, nfk: Netflix Kids, dnp: Disney+, amp: Prime, atp: Apple TV+ 132 | - hbm: HBO Max, pmp: Paramount+, pcp: Peacock, hlu: Hulu, cru: Crunchyroll 133 | - cts: Curiosity Stream, mgl: MagellanTV, jhs: JioHotstar, zee: Zee5 134 | - hay: Hayu, nlz: NLZIET, sst: SkyShowtime, cpd: Canal+, dpe: Discovery+ 135 | - stz: Starz, mbi: Mubi, vik: Viki, sgo: Sky Go, sonyliv: Sony Liv 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stremio Streaming Catalogs Addon 2 | 3 | ![image](https://user-images.githubusercontent.com/6817390/216839228-f0d09dfd-e76b-4d23-bf4f-cab09febd1ef.png) 4 | 5 | A Stremio addon that provides streaming catalogs from various popular streaming services including Netflix, Disney+, HBO Max, Prime Video, Apple TV+, and many more. This addon allows users to browse and discover content from multiple streaming platforms directly within Stremio. 6 | 7 | ## Features 8 | 9 | - **Multiple Streaming Services**: Support for 20+ streaming platforms 10 | - **Country-based Filtering**: Filter providers by country/region 11 | - **Web Interface**: Modern Vue.js web interface for configuration 12 | - **Real-time Catalogs**: Live streaming catalogs from various services 13 | - **Easy Installation**: Simple addon installation process 14 | 15 | ## Supported Streaming Services 16 | 17 | - Netflix & Netflix Kids 18 | - Disney+ 19 | - HBO Max 20 | - Prime Video 21 | - Apple TV+ 22 | - Paramount+ 23 | - Peacock Premium 24 | - Hulu 25 | - Curiosity Stream 26 | - MagellanTV 27 | - Crunchyroll 28 | - Hayu 29 | - Clarovideo 30 | - Globoplay 31 | - And many more... 32 | 33 | ## Local Development Setup 34 | 35 | ### Prerequisites 36 | 37 | - **Node.js** (v16 or higher) 38 | - **npm** or **yarn** 39 | 40 | ### Installation 41 | 42 | 1. **Clone the repository** 43 | ```bash 44 | git clone https://github.com/rleroi/Stremio-Streaming-Catalogs-Addon.git 45 | cd Stremio-Streaming-Catalogs-Addon 46 | ``` 47 | 48 | 2. **Install backend dependencies** 49 | ```bash 50 | npm install 51 | ``` 52 | 53 | 3. **Install frontend dependencies** 54 | ```bash 55 | cd vue 56 | npm install 57 | cd .. 58 | ``` 59 | 60 | ### Running Locally 61 | 62 | #### Option 1: Development Mode (Recommended) 63 | 64 | 1. **Start the backend server** 65 | ```bash 66 | npm run dev 67 | ``` 68 | This will start the backend server with nodemon for auto-reloading on changes. 69 | 70 | 2. **In a new terminal, start the frontend development server** 71 | ```bash 72 | cd vue 73 | npm run dev 74 | ``` 75 | This will start the Vue development server (typically on http://localhost:5173). 76 | 77 | 3. **Build the frontend for production** 78 | ```bash 79 | cd vue 80 | npm run build 81 | cd .. 82 | ``` 83 | This creates the `vue/dist` folder that the backend serves. 84 | 85 | #### Option 2: Production Mode 86 | 87 | 1. **Build the frontend** 88 | ```bash 89 | cd vue 90 | npm run build 91 | cd .. 92 | ``` 93 | 94 | 2. **Start the production server** 95 | ```bash 96 | npm start 97 | ``` 98 | 99 | ### Accessing the Application 100 | 101 | ### Caching System 102 | 103 | The addon includes a caching system to improve performance and reduce API calls: 104 | 105 | - **Cache Location**: `./cache/catalog-cache.json` 106 | - **Cache Duration**: 6 hours (configurable) 107 | - **Environment Variables**: 108 | - `USE_CACHE=true/false` - Enable/disable caching (default: true) 109 | - `FORCE_REFRESH=true/false` - Force refresh and ignore cache (default: false) 110 | 111 | **Development Commands**: 112 | - Clear cache: `curl http://localhost:7700/clear-cache` (development only) 113 | - Force refresh: `FORCE_REFRESH=true npm run dev` 114 | 115 | **Benefits**: 116 | - Faster startup times during development 117 | - Reduced API rate limiting 118 | - Consistent data for testing 119 | 120 | - **Backend API**: http://localhost:7700 121 | - **Frontend (dev)**: http://localhost:5173 (when running `npm run dev` in vue folder) 122 | - **Production**: http://localhost:7700 (serves the built frontend) 123 | 124 | ### Environment Variables 125 | 126 | The project uses environment variables for configuration. You'll need to set up the following: 127 | 128 | #### Backend Environment Variables (Optional) 129 | 130 | Create a `.env` file in the root directory for backend configuration: 131 | 132 | ```env 133 | # Optional: Mixpanel analytics key for tracking 134 | MIXPANEL_KEY=your_mixpanel_key_here 135 | 136 | # Optional: Port for the server (default: 7700) 137 | PORT=7700 138 | 139 | # Optional: Refresh interval for catalogs in milliseconds (default: 21600000 = 6 hours) 140 | REFRESH_INTERVAL=21600000 141 | 142 | # Optional: Set to 'production' for production mode 143 | NODE_ENV=development 144 | ``` 145 | 146 | #### Frontend Environment Variables 147 | 148 | The project includes pre-configured environment files in the `vue` directory: 149 | 150 | - `vue/.env.development` - Development configuration (points to localhost:7700) 151 | - `vue/.env` - Production configuration 152 | 153 | **Note**: The `VITE_APP_URL` is used by the frontend to generate the correct addon installation URL. The included files are already configured for both development and production environments. 154 | 155 | ### Troubleshooting 156 | 157 | #### Server Crashes on Startup 158 | 159 | If the backend server crashes during startup (especially during `loadNewCatalog()`), this is likely due to: 160 | 161 | 1. **Network connectivity issues** - The addon fetches catalogs from external APIs 162 | 2. **Rate limiting** - Some APIs may have rate limits 163 | 3. **API changes** - External APIs may have changed their endpoints 164 | 165 | **Solutions:** 166 | - Check your internet connection 167 | - Wait a few minutes and try again (rate limiting) 168 | - The server will automatically restart with nodemon when you make changes 169 | - For development, you can comment out some of the catalog loading calls in `index.js` to reduce API calls 170 | 171 | #### Environment File Issues 172 | 173 | - The project includes pre-configured environment files 174 | - If you need to modify the configuration, edit the existing `.env` files 175 | - Restart the servers after changing environment variables 176 | 177 | ### Development Scripts 178 | 179 | #### Backend (Root Directory) 180 | - `npm start` - Start production server 181 | - `npm run dev` - Start development server with auto-reload 182 | 183 | #### Frontend (vue Directory) 184 | - `npm run dev` - Start development server with hot reload 185 | - `npm run build` - Build for production 186 | - `npm run preview` - Preview production build 187 | 188 | ## Project Structure 189 | 190 | ``` 191 | Stremio-Streaming-Catalogs-Addon/ 192 | ├── index.js # Main Express server 193 | ├── addon.js # Stremio addon logic 194 | ├── package.json # Backend dependencies 195 | ├── vue/ # Frontend Vue.js application 196 | │ ├── src/ 197 | │ │ ├── App.vue # Main Vue component 198 | │ │ ├── components/ # Vue components 199 | │ │ └── main.js # Vue app entry point 200 | │ ├── public/ # Static assets 201 | │ ├── dist/ # Built frontend (generated) 202 | │ └── package.json # Frontend dependencies 203 | └── README.md 204 | ``` 205 | 206 | ## Contributing 207 | 208 | 1. Fork the repository 209 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 210 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 211 | 4. Push to the branch (`git push origin feature/amazing-feature`) 212 | 5. Open a Pull Request 213 | 214 | ## License 215 | 216 | This project is licensed under the ISC License. 217 | 218 | ## Support 219 | 220 | - **Discord**: [Join our Discord server](https://discord.gg/uggmYJ7jVX) 221 | - **Ko-fi**: [Support the project](https://ko-fi.com/rab1t) 222 | -------------------------------------------------------------------------------- /docs/netflix.graphql: -------------------------------------------------------------------------------- 1 | # Netflix Tudum GraphQL Schema 2 | # Reverse engineered from window.netflix.reactContext and API responses 3 | # Endpoint: https://pulse.prod.cloud.netflix.com/graphql 4 | # 5 | # NOTE: This is a reverse-engineered schema. Some types and fields may be incomplete. 6 | # The API uses persisted queries, so the full query structure is not always visible. 7 | 8 | # How to Find Persisted Query IDs: 9 | # 1. Open browser DevTools (F12) 10 | # 2. Go to Network tab 11 | # 3. Filter by "graphql" 12 | # 4. Navigate to a Netflix Tudum Top 10 page 13 | # 5. Find the GraphQL request to pulse.prod.cloud.netflix.com/graphql 14 | # 6. Inspect the request payload to see the persistedQuery.id and version 15 | # 16 | # The persisted query ID and version may change - always verify via Network tab 17 | 18 | type Query { 19 | pulsePage( 20 | url: String! 21 | params: PulsePageParams 22 | withProfile: Boolean 23 | ): PulsePage 24 | } 25 | 26 | type PulsePage { 27 | id: ID 28 | kind: String # e.g., "TOP10" 29 | slug: String 30 | language: String 31 | sections: [PulseSection] 32 | seo: PulsePageSEO 33 | theme: PulsePageTheme 34 | currentCountry: ConsumerCountry 35 | } 36 | 37 | type PulseSection { 38 | __typename: String 39 | id: ID 40 | guid: String 41 | entities: [PulseEntity] 42 | header: PulseSectionHeader 43 | footer: PulseSectionFooter 44 | presentation: PulsePresentation 45 | loggingData: PulseLoggingData 46 | } 47 | 48 | # Top 10 specific entity 49 | type PulseTop10ItemEntity implements PulseEntity { 50 | __typename: "PulseTop10ItemEntity" 51 | id: ID 52 | guid: ID 53 | theme: PulseEntityTheme 54 | 55 | top10: Top10Data 56 | top10Video: Top10PulseVideo 57 | artwork: Top10PulseImages 58 | countryRanks: [Top10CountryRank] 59 | displayVideo: PulseDisplayVideo 60 | slug: String 61 | } 62 | 63 | type Top10Data { 64 | __typename: "Top10Data" 65 | category: String # "SERIES", "ENGLISH_MOVIES", etc. 66 | weeklyRank: Int 67 | weeklyHoursViewed: Int 68 | weeklyViews: Int 69 | cumulativeWeeksInTop10: Int 70 | weekEndDate: String # ISO date format 71 | runtime: Float # Hours 72 | runtimeVaries: Boolean 73 | videoId: Int 74 | isStaggeredLaunch: Boolean 75 | episodeLaunchDetails: EpisodeLaunchDetails 76 | } 77 | 78 | type Top10PulseVideo { 79 | __typename: "Top10PulseVideo" 80 | title: String 81 | videoId: Int 82 | shortSynopsis: String 83 | releaseYear: Int 84 | maturityRating: String # e.g., "PG-13", "R", "TV-MA" 85 | runtimeSeconds: Int 86 | parentShow: Top10PulseVideo 87 | number: Int 88 | } 89 | 90 | type Top10CountryRank { 91 | __typename: "Top10CountryRank" 92 | countryId: String # ISO2 country code 93 | rank: Int 94 | } 95 | 96 | type Top10PulseImages { 97 | __typename: "Top10PulseImages" 98 | logoArt: Top10PulseImage 99 | sdpArt: Top10PulseImage 100 | storyArt: Top10PulseImage 101 | } 102 | 103 | type Top10PulseImage { 104 | __typename: "Top10PulseImage" 105 | alternativeText: String 106 | urlsSized: [PulseImageSizeResult] 107 | } 108 | 109 | type PulseImageSizeResult { 110 | __typename: "PulseImageSizeResult" 111 | url: String 112 | width: Int 113 | height: Int 114 | dominantBackgroundColor: String 115 | focalPoint: PulseImageFocalPoint 116 | } 117 | 118 | type PulseImageFocalPoint { 119 | __typename: "PulseImageFocalPoint" 120 | x: Float 121 | y: Float 122 | } 123 | 124 | type PulseDisplayVideo { 125 | __typename: "PulseDisplayVideo" 126 | video: PulseVideo 127 | titlePageSlug: String 128 | } 129 | 130 | type PulseVideo { 131 | __typename: String # "Movie", "Show", etc. 132 | unifiedEntityId: String # e.g., "Video:81654736" 133 | videoId: Int 134 | isPlayable: Boolean 135 | isInPlaylist: Boolean 136 | isInRemindMeList: Boolean 137 | isAvailable: Boolean 138 | availabilityStartTime: String # ISO datetime 139 | promoVideo: PromoVideo 140 | } 141 | 142 | type PromoVideo { 143 | __typename: "PromoVideo" 144 | video: SupplementalVideo 145 | } 146 | 147 | type SupplementalVideo { 148 | __typename: "Supplemental" 149 | videoId: Int 150 | artwork: Image 151 | } 152 | 153 | type Image { 154 | __typename: "Image" 155 | url: String 156 | alternativeText: String 157 | } 158 | 159 | type ConsumerCountry { 160 | __typename: "ConsumerCountry" 161 | code: String # ISO2 country code 162 | } 163 | 164 | type PulsePageSEO { 165 | __typename: "PulsePageSEO" 166 | title: String 167 | description: String 168 | ogTitle: String 169 | ogDescription: String 170 | ogImgWithAlt: PulseImage 171 | noIndex: Boolean 172 | } 173 | 174 | type PulsePageTheme { 175 | __typename: "PulsePageTheme" 176 | background: PulseBackground 177 | defaultEntityTheme: PulseEntityTheme 178 | } 179 | 180 | type PulseBackground { 181 | __typename: String # e.g., "PulseStaticBackground" 182 | backgroundColor: PulseColor 183 | } 184 | 185 | type PulseColor { 186 | __typename: "PulseColor" 187 | hexString: String 188 | } 189 | 190 | type PulseEntityTheme { 191 | __typename: "PulseEntityTheme" 192 | accentColor: PulseColor 193 | backgroundColor: PulseColor 194 | } 195 | 196 | # Other entity types found in sections 197 | type PulseLinkEntity implements PulseEntity { 198 | __typename: "PulseLinkEntity" 199 | id: ID 200 | slug: String 201 | text: String 202 | url: String 203 | } 204 | 205 | type PulseCustomEntity implements PulseEntity { 206 | __typename: "PulseCustomEntity" 207 | id: ID 208 | type: String 209 | data: JSON 210 | } 211 | 212 | type PulseVideoItemEntity implements PulseEntity { 213 | __typename: "PulseVideoItemEntity" 214 | id: ID 215 | slug: String 216 | displayHeadline: String 217 | headline: PulseRichTextValue 218 | body: PulseRichTextValue 219 | timestamp: String # ISO datetime 220 | runtime: String 221 | previewImage: PulseImage 222 | } 223 | 224 | type PulseRichTextValue { 225 | __typename: "PulseSimpleRichTextValue" 226 | html: String 227 | } 228 | 229 | type PulseImage { 230 | __typename: "PulseImage" 231 | url: String 232 | alternativeText: String 233 | urlsSized: [PulseImageSizeResult] 234 | } 235 | 236 | # Input types 237 | input PulsePageParams { 238 | isWebView: Boolean 239 | queryString: String 240 | } 241 | 242 | # Persisted Query Structure 243 | # Netflix uses persisted queries identified by ID and version 244 | # Example: 245 | # { 246 | # "operationName": "PulsePageQuery", 247 | # "variables": { 248 | # "withProfile": false, 249 | # "url": "/top10/united-kingdom/tv", 250 | # "params": { "isWebView": false } 251 | # }, 252 | # "extensions": { 253 | # "persistedQuery": { 254 | # "id": "10ca20d3-e892-44af-b52a-f1107400a873", 255 | # "version": 102 256 | # } 257 | # } 258 | # } 259 | 260 | # Known Persisted Queries 261 | # - PulsePageQuery: id="10ca20d3-e892-44af-b52a-f1107400a873", version=102 262 | # Used for Top 10 pages 263 | 264 | # URL Patterns for PulsePageQuery 265 | # - Country-specific: /top10/{country-slug}/tv or /top10/{country-slug} (movies) 266 | # - Global: /top10/global/tv or /top10/global (movies) 267 | # - Historical: /top10/{country-slug}/tv?week=YYYY-MM-DD 268 | 269 | # Error Types 270 | # - UNAUTHENTICATED: Field requires authentication (can be ignored for public data) 271 | # - NOT_FOUND: Video or entity not found 272 | # - InvalidSyntax: Query syntax error 273 | 274 | # Notes 275 | # - The persisted query ID and version may change - monitor for updates 276 | # - Some fields return null for unauthenticated requests (this is expected) 277 | # - The week parameter in URL query string may not work for all historical dates 278 | # - Country slugs must match Netflix's URL structure (see scripts/netflixTop10.js) 279 | 280 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import cors from 'cors'; 3 | import Mixpanel from 'mixpanel'; 4 | import { fileURLToPath } from 'url'; 5 | import path from 'path'; 6 | import fs from 'fs'; 7 | import justwatch from '../services/justwatch.js'; 8 | import { loadCatalogCache, saveCatalogCache, clearCatalogCache } from '../utils/cache.js'; 9 | import { handleConfiguredManifest, handleDefaultManifest } from './routes/manifest.js'; 10 | import { handleCatalog } from './routes/catalog.js'; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = path.dirname(__filename); 14 | 15 | const REFRESH_INTERVAL = process.env.REFRESH_INTERVAL || 21600000; // 6 hours in milliseconds 16 | const USE_CACHE = process.env.USE_CACHE !== 'false'; // Default to true 17 | const FORCE_REFRESH = process.env.FORCE_REFRESH === 'true'; // Default to false 18 | 19 | // Production error handling 20 | if (process.env.NODE_ENV === 'production') { 21 | const errorLog = fs.createWriteStream(path.join(__dirname, '../../vue/dist/error.log')); 22 | process.stderr.write = errorLog.write.bind(errorLog); 23 | 24 | process.on('uncaughtException', function (err) { 25 | console.error((err && err.stack) ? err.stack : err); 26 | }); 27 | } 28 | 29 | const app = express(); 30 | app.set('trust proxy', true); 31 | app.use(cors()); 32 | app.use(express.static(path.join(__dirname, '../../vue/dist'))); 33 | 34 | // Initialize Mixpanel 35 | let mixpanel = null; 36 | if (process.env.MIXPANEL_KEY) { 37 | mixpanel = Mixpanel.init(process.env.MIXPANEL_KEY); 38 | } 39 | 40 | // Catalog data storage 41 | let movies = { 42 | 'nfx': [], 'nfk': [], 'dnp': [], 'amp': [], 'atp': [], 'pmp': [], 'hbm': [], 43 | 'hlu': [], 'pcp': [], 'cru': [], 'jhs': [], 'zee': [], 'vil': [], 'clv': [], 44 | 'gop': [], 'mgl': [], 'cts': [], 'sst': [], 'nlz': [], 'stz': [], 'mbi': [], 45 | 'vik': [], 'sgo': [], 'sonyliv': [], 'cpd': [], 'mp9': [], 46 | }; 47 | 48 | let series = { 49 | 'nfx': [], 'nfk': [], 'dnp': [], 'amp': [], 'atp': [], 'pmp': [], 'hbm': [], 50 | 'hlu': [], 'pcp': [], 'cru': [], 'jhs': [], 'zee': [], 'vil': [], 'clv': [], 51 | 'gop': [], 'mgl': [], 'cts': [], 'sst': [], 'nlz': [], 'stz': [], 'vik': [], 52 | 'sgo': [], 'sonyliv': [], 'hay': [], 'cpd': [], 'dpe': [], 'mp9': [], 53 | }; 54 | 55 | /** 56 | * Load catalog data (from cache or fresh fetch) 57 | */ 58 | async function loadNewCatalog() { 59 | console.log('loadNewCatalog'); 60 | 61 | // Clear cache if force refresh is enabled 62 | if (FORCE_REFRESH) { 63 | clearCatalogCache(); 64 | } 65 | 66 | // Try to load from cache first (if caching is enabled) 67 | if (USE_CACHE) { 68 | const cachedData = loadCatalogCache(REFRESH_INTERVAL); 69 | if (cachedData) { 70 | Object.assign(movies, cachedData.movies); 71 | Object.assign(series, cachedData.series); 72 | console.log('Catalog data loaded from cache'); 73 | return; 74 | } 75 | } 76 | 77 | // If no cache or expired, fetch fresh data 78 | console.log('Fetching fresh catalog data...'); 79 | movies.nfx = await justwatch.getMetas('MOVIE', ['nfx'], 'GB'); 80 | movies.nfk = await justwatch.getMetas('MOVIE', ['nfk'], 'US'); 81 | movies.dnp = await justwatch.getMetas('MOVIE', ['dnp'], 'GB'); 82 | movies.atp = await justwatch.getMetas('MOVIE', ['atp'], 'GB'); 83 | movies.amp = await justwatch.getMetas('MOVIE', ['amp'], 'US'); 84 | movies.pmp = await justwatch.getMetas('MOVIE', ['pmp'], 'US'); 85 | movies.hbm = await justwatch.getMetas('MOVIE', ['hbm'], 'NL'); 86 | movies.hlu = await justwatch.getMetas('MOVIE', ['hlu'], 'US'); 87 | movies.pcp = await justwatch.getMetas('MOVIE', ['pcp'], 'US'); 88 | movies.cts = await justwatch.getMetas('MOVIE', ['cts'], 'US'); 89 | movies.mgl = await justwatch.getMetas('MOVIE', ['mgl'], 'US'); 90 | movies.cru = await justwatch.getMetas('MOVIE', ['cru'], 'US'); 91 | movies.jhs = await justwatch.getMetas('MOVIE', ['jhs'], 'IN', 'in'); 92 | movies.zee = await justwatch.getMetas('MOVIE', ['zee'], 'IN', 'in'); 93 | movies.vil = await justwatch.getMetas('MOVIE', ['vil'], 'NL', 'nl'); 94 | movies.nlz = await justwatch.getMetas('MOVIE', ['nlz'], 'NL', 'nl'); 95 | movies.sst = await justwatch.getMetas('MOVIE', ['sst'], 'NL', 'nl'); 96 | movies.clv = await justwatch.getMetas('MOVIE', ['clv'], 'BR', 'br'); 97 | movies.gop = await justwatch.getMetas('MOVIE', ['gop'], 'BR', 'br'); 98 | movies.cpd = await justwatch.getMetas('MOVIE', ['cpd'], 'FR', 'fr'); 99 | movies.stz = await justwatch.getMetas('MOVIE', ['stz'], 'US'); 100 | movies.mbi = await justwatch.getMetas('MOVIE', ['mbi'], 'US'); 101 | movies.vik = await justwatch.getMetas('MOVIE', ['vik'], 'US'); 102 | movies.sgo = await justwatch.getMetas('MOVIE', ['sgo'], 'DE', 'de'); 103 | movies.sonyliv = await justwatch.getMetas('MOVIE', ['sonyliv'], 'IN', 'hi'); 104 | movies.mp9 = await justwatch.getMetas('MOVIE', ['mp9'], 'ES', 'es'); 105 | 106 | series.nfx = await justwatch.getMetas('SHOW', ['nfx'], 'GB'); 107 | series.nfk = await justwatch.getMetas('SHOW', ['nfk'], 'US'); 108 | series.dnp = await justwatch.getMetas('SHOW', ['dnp'], 'GB'); 109 | series.atp = await justwatch.getMetas('SHOW', ['atp'], 'GB'); 110 | series.hay = await justwatch.getMetas('SHOW', ['hay'], 'GB'); 111 | series.dpe = await justwatch.getMetas('SHOW', ['dpe'], 'GB'); 112 | series.amp = await justwatch.getMetas('SHOW', ['amp'], 'US'); 113 | series.pmp = await justwatch.getMetas('SHOW', ['pmp'], 'US'); 114 | series.hbm = await justwatch.getMetas('SHOW', ['hbm'], 'NL'); 115 | series.hlu = await justwatch.getMetas('SHOW', ['hlu'], 'US'); 116 | series.pcp = await justwatch.getMetas('SHOW', ['pcp'], 'US'); 117 | series.cru = await justwatch.getMetas('SHOW', ['cru'], 'US'); 118 | series.cts = await justwatch.getMetas('SHOW', ['cts'], 'US'); 119 | series.mgl = await justwatch.getMetas('SHOW', ['mgl'], 'US'); 120 | series.jhs = await justwatch.getMetas('SHOW', ['jhs'], 'IN', 'in'); 121 | series.zee = await justwatch.getMetas('SHOW', ['zee'], 'IN', 'in'); 122 | series.vil = await justwatch.getMetas('SHOW', ['vil'], 'NL', 'nl'); 123 | series.nlz = await justwatch.getMetas('SHOW', ['nlz'], 'NL', 'nl'); 124 | series.sst = await justwatch.getMetas('SHOW', ['sst'], 'NL', 'nl'); 125 | series.clv = await justwatch.getMetas('SHOW', ['clv'], 'BR', 'br'); 126 | series.gop = await justwatch.getMetas('SHOW', ['gop'], 'BR', 'br'); 127 | series.cpd = await justwatch.getMetas('SHOW', ['cpd'], 'FR', 'fr'); 128 | series.stz = await justwatch.getMetas('SHOW', ['stz'], 'US'); 129 | series.vik = await justwatch.getMetas('SHOW', ['vik'], 'US'); 130 | series.sgo = await justwatch.getMetas('SHOW', ['sgo'], 'DE', 'de'); 131 | series.sonyliv = await justwatch.getMetas('SHOW', ['sonyliv'], 'IN', 'hi'); 132 | series.mp9 = await justwatch.getMetas('SHOW', ['mp9'], 'ES', 'es'); 133 | 134 | // Save to cache (if caching is enabled) 135 | if (USE_CACHE) { 136 | saveCatalogCache(movies, series); 137 | } 138 | console.log('done'); 139 | } 140 | 141 | // Routes 142 | app.get('/:configuration/manifest.json', (req, res) => { 143 | handleConfiguredManifest(req, res, mixpanel); 144 | }); 145 | 146 | app.get('/manifest.json', (req, res) => { 147 | handleDefaultManifest(req, res, mixpanel); 148 | }); 149 | 150 | app.get('/:configuration?/catalog/:type/:id/:extra?.json', (req, res) => { 151 | handleCatalog(req, res, movies, series, mixpanel); 152 | }); 153 | 154 | // Development endpoint to clear cache 155 | if (process.env.NODE_ENV !== 'production') { 156 | app.get('/clear-cache', function (req, res) { 157 | clearCatalogCache(); 158 | res.json({ message: 'Cache cleared successfully' }); 159 | }); 160 | } 161 | 162 | // Fallback to Vue 163 | app.get(/.*/, (req, res) => { 164 | res.setHeader('Cache-Control', 'max-age=86400,stale-while-revalidate=86400,stale-if-error=86400,public'); 165 | res.setHeader('content-type', 'text/html'); 166 | res.sendFile(path.join(__dirname, '../../vue/dist/index.html')); 167 | }); 168 | 169 | // Initialize catalog loading 170 | loadNewCatalog(); 171 | setInterval(loadNewCatalog, REFRESH_INTERVAL); 172 | 173 | export default app; 174 | 175 | -------------------------------------------------------------------------------- /src/server/routes/manifest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Manifest route handlers 3 | */ 4 | 5 | /** 6 | * Build catalog list from selected providers 7 | */ 8 | function buildProviderCatalogs(selectedProviders) { 9 | const catalogs = []; 10 | 11 | const providerMap = { 12 | 'nfx': { name: 'Netflix', types: ['movie', 'series'] }, 13 | 'nfk': { name: 'Netflix Kids', types: ['movie', 'series'] }, 14 | 'hbm': { name: 'HBO Max', types: ['movie', 'series'] }, 15 | 'dnp': { name: 'Disney+', types: ['movie', 'series'] }, 16 | 'hlu': { name: 'Hulu', types: ['movie', 'series'] }, 17 | 'amp': { name: 'Prime Video', types: ['movie', 'series'] }, 18 | 'pmp': { name: 'Paramount+', types: ['movie', 'series'] }, 19 | 'atp': { name: 'Apple TV+', types: ['movie', 'series'] }, 20 | 'pcp': { name: 'Peacock', types: ['movie', 'series'] }, 21 | 'pct': { name: 'Peacock', types: ['movie', 'series'] }, // Legacy alias 22 | 'cru': { name: 'Crunchyroll', types: ['movie', 'series'] }, 23 | 'fmn': { name: 'Crunchyroll', types: ['movie', 'series'] }, // Legacy alias 24 | 'jhs': { name: 'JioHotstar', types: ['movie', 'series'] }, 25 | 'hst': { name: 'JioHotstar', types: ['movie', 'series'] }, // Legacy alias 26 | 'zee': { name: 'Zee5', types: ['movie', 'series'] }, 27 | 'vil': { name: 'Videoland', types: ['movie', 'series'] }, 28 | 'clv': { name: 'Clarovideo', types: ['movie', 'series'] }, 29 | 'gop': { name: 'Globoplay', types: ['movie', 'series'] }, 30 | 'hay': { name: 'Hayu', types: ['series'] }, 31 | 'nlz': { name: 'NLZIET', types: ['movie', 'series'] }, 32 | 'sst': { name: 'SkyShowtime', types: ['movie', 'series'] }, 33 | 'mgl': { name: 'MagellanTV', types: ['movie', 'series'] }, 34 | 'cts': { name: 'Curiosity Stream', types: ['movie', 'series'] }, 35 | 'cpd': { name: 'Canal+', types: ['movie', 'series'] }, 36 | 'stz': { name: 'Starz', types: ['movie', 'series'] }, 37 | 'dpe': { name: 'Discovery+', types: ['series'] }, 38 | 'mbi': { name: 'Mubi', types: ['movie'] }, 39 | 'vik': { name: 'Rakuten Viki', types: ['movie', 'series'] }, 40 | 'sgo': { name: 'Sky Go', types: ['movie', 'series'] }, 41 | 'sonyliv': { name: 'Sony Liv', types: ['movie', 'series'] }, 42 | 'mp9': { name: 'Movistar+', types: ['movie', 'series'] }, 43 | }; 44 | 45 | const seen = new Set(); 46 | 47 | for (const provider of selectedProviders.split(',')) { 48 | const providerInfo = providerMap[provider]; 49 | if (providerInfo && !seen.has(provider)) { 50 | seen.add(provider); 51 | for (const type of providerInfo.types) { 52 | catalogs.push({ 53 | id: provider === 'pct' ? 'pcp' : provider === 'hst' ? 'jhs' : provider === 'fmn' ? 'cru' : provider, 54 | type, 55 | name: providerInfo.name, 56 | }); 57 | } 58 | } 59 | } 60 | 61 | return catalogs; 62 | } 63 | 64 | /** 65 | * Build Netflix Top 10 catalogs 66 | */ 67 | function buildNetflixTop10Catalogs(netflixTop10Global, netflixTop10Country, netflixTop10CountryCode) { 68 | const catalogs = []; 69 | 70 | // Default to true for global, false for country (backward compatibility) 71 | const enableNetflixTop10Global = netflixTop10Global === undefined || netflixTop10Global === '1'; 72 | const enableNetflixTop10Country = netflixTop10Country === '1'; 73 | const top10CountryCode = netflixTop10CountryCode || null; 74 | 75 | if (enableNetflixTop10Global) { 76 | catalogs.push({ 77 | id: 'netflix-top10-global', 78 | type: 'movie', 79 | name: 'Netflix Top 10 Movies (Global)', 80 | }); 81 | catalogs.push({ 82 | id: 'netflix-top10-global', 83 | type: 'series', 84 | name: 'Netflix Top 10 Shows (Global)', 85 | }); 86 | } 87 | 88 | // Add country-specific Netflix Top 10 catalogs based on selected country code 89 | if (enableNetflixTop10Country && top10CountryCode) { 90 | const countryCodeUpper = top10CountryCode.toUpperCase(); 91 | catalogs.push({ 92 | id: `netflix-top10-${countryCodeUpper}`, 93 | type: 'movie', 94 | name: `Netflix Top 10 Movies (${countryCodeUpper})`, 95 | }); 96 | catalogs.push({ 97 | id: `netflix-top10-${countryCodeUpper}`, 98 | type: 'series', 99 | name: `Netflix Top 10 Shows (${countryCodeUpper})`, 100 | }); 101 | } 102 | 103 | return catalogs; 104 | } 105 | 106 | /** 107 | * Configured manifest route handler 108 | */ 109 | export function handleConfiguredManifest(req, res, mixpanel) { 110 | res.setHeader('Cache-Control', 'max-age=86400,stale-while-revalidate=86400,stale-if-error=86400,public'); 111 | res.setHeader('content-type', 'application/json'); 112 | 113 | // Parse config 114 | const buffer = Buffer(req.params?.configuration || '', 'base64'); 115 | const configParts = buffer.toString('ascii')?.split(':'); 116 | const [selectedProviders, rpdbKey, countryCode, installedAt, netflixTop10Global, netflixTop10Country, netflixTop10CountryCode] = configParts; 117 | 118 | mixpanel && mixpanel.track('install', { 119 | ip: req.ip, 120 | distinct_id: req.ip.replace(/\.|:/g, 'Z'), 121 | configuration: req.params.configuration, 122 | selectedProviders, 123 | rpdbKey, 124 | countryCode, 125 | installedAt, 126 | }); 127 | 128 | const catalogs = [ 129 | ...buildProviderCatalogs(selectedProviders || ''), 130 | ...buildNetflixTop10Catalogs(netflixTop10Global, netflixTop10Country, netflixTop10CountryCode), 131 | ]; 132 | 133 | res.send({ 134 | id: 'pw.ers.netflix-catalog', 135 | logo: 'https://play-lh.googleusercontent.com/TBRwjS_qfJCSj1m7zZB93FnpJM5fSpMA_wUlFDLxWAb45T9RmwBvQd5cWR5viJJOhkI', 136 | version: process.env.npm_package_version, 137 | name: 'Streaming Catalogs', 138 | description: 'Your favourite streaming services!', 139 | catalogs: catalogs, 140 | resources: ['catalog'], 141 | types: ['movie', 'series'], 142 | idPrefixes: ['tt'], 143 | behaviorHints: { 144 | configurable: true, 145 | } 146 | }); 147 | } 148 | 149 | /** 150 | * Default manifest route handler 151 | */ 152 | export function handleDefaultManifest(req, res, mixpanel) { 153 | res.setHeader('Cache-Control', 'max-age=86400,stale-while-revalidate=86400,stale-if-error=86400,public'); 154 | res.setHeader('content-type', 'application/json'); 155 | 156 | mixpanel && mixpanel.track('install', { 157 | ip: req.ip, 158 | distinct_id: req.ip.replace(/\.|:/g, 'Z'), 159 | }); 160 | 161 | res.send({ 162 | id: 'pw.ers.netflix-catalog', 163 | logo: 'https://play-lh.googleusercontent.com/TBRwjS_qfJCSj1m7zZB93FnpJM5fSpMA_wUlFDLxWAb45T9RmwBvQd5cWR5viJJOhkI', 164 | version: process.env.npm_package_version, 165 | name: 'Streaming Catalogs', 166 | description: 'Trending movies and series on Netflix, HBO Max, Disney+, Apple TV+ and more. Configure to choose your favourite services.', 167 | catalogs: [ 168 | { 169 | id: 'nfx', 170 | type: 'movie', 171 | name: 'Netflix', 172 | }, { 173 | id: 'nfx', 174 | type: 'series', 175 | name: 'Netflix', 176 | }, { 177 | id: 'hbm', 178 | type: 'movie', 179 | name: 'HBO Max', 180 | }, { 181 | id: 'hbm', 182 | type: 'series', 183 | name: 'HBO Max', 184 | }, { 185 | id: 'dnp', 186 | type: 'movie', 187 | name: 'Disney+', 188 | }, { 189 | id: 'dnp', 190 | type: 'series', 191 | name: 'Disney+', 192 | }, { 193 | id: 'amp', 194 | type: 'movie', 195 | name: 'Prime Video', 196 | }, { 197 | id: 'amp', 198 | type: 'series', 199 | name: 'Prime Video', 200 | }, { 201 | id: 'atp', 202 | type: 'movie', 203 | name: 'Apple TV+', 204 | }, { 205 | id: 'atp', 206 | type: 'series', 207 | name: 'Apple TV+', 208 | }, { 209 | id: 'netflix-top10-global', 210 | type: 'movie', 211 | name: 'Netflix Top 10 Movies (Global)', 212 | }, { 213 | id: 'netflix-top10-global', 214 | type: 'series', 215 | name: 'Netflix Top 10 Shows (Global)', 216 | }, 217 | ], 218 | resources: ['catalog'], 219 | types: ['movie', 'series'], 220 | idPrefixes: ['tt'], 221 | behaviorHints: { 222 | configurable: true, 223 | } 224 | }); 225 | } 226 | 227 | -------------------------------------------------------------------------------- /docs/netflix.md: -------------------------------------------------------------------------------- 1 | # Netflix Tudum Top 10 API Documentation 2 | 3 | This document describes the Netflix Tudum Top 10 data sources discovered through reverse engineering. 4 | 5 | ## Data Sources 6 | 7 | Netflix Tudum exposes Top 10 data through multiple endpoints: 8 | 9 | ### 1. TSV Files (Public Data) 10 | 11 | Netflix provides three TSV files containing Top 10 data: 12 | 13 | #### Global Most Popular (All-Time) 14 | - **URL**: `https://www.netflix.com/tudum/top10/data/most-popular.tsv` 15 | - **Description**: Contains the most popular titles across all time (first 91 days) 16 | - **Categories**: 17 | - Films (English) 18 | - Films (Non-English) 19 | - TV (English) 20 | - TV (Non-English) 21 | - **Columns**: `category`, `rank`, `show_title`, `season_title`, `hours_viewed_first_91_days`, `runtime`, `views_first_91_days` 22 | - **Use Case**: All-time most popular content 23 | 24 | #### All Weeks Global 25 | - **URL**: `http://www.netflix.com/tudum/top10/data/all-weeks-global.tsv` 26 | - **Description**: Complete historical global Top 10 data for all weeks since 2021 27 | - **Columns**: `week`, `category`, `weekly_rank`, `show_title`, `season_title`, `weekly_hours_viewed`, `runtime`, `weekly_views`, `cumulative_weeks_in_top_10` 28 | - **Categories**: 29 | - Films (English) 30 | - Films (Non-English) 31 | - TV (English) 32 | - TV (Non-English) 33 | - **Time Range**: From 2021-07-04 to present 34 | - **Use Case**: Global Top 10 lists, historical weekly trends (aggregated across all countries) 35 | - **Update Frequency**: Weekly (typically updated on Sundays) 36 | - **Limitation**: Does not include release year, making it difficult to match titles to IMDB/TMDB IDs 37 | 38 | #### All Countries, All Weeks 39 | - **URL**: `https://www.netflix.com/tudum/top10/data/all-weeks-countries.tsv` 40 | - **Description**: Complete historical Top 10 data for all countries and all weeks since 2021 41 | - **Columns**: `country_name`, `country_iso2`, `week`, `category`, `weekly_rank`, `show_title`, `season_title`, `cumulative_weeks_in_top_10` 42 | - **Categories**: `Films` or `TV` 43 | - **Countries**: 90+ countries (ISO2 codes) 44 | - **Time Range**: From 2021-07-04 to present 45 | - **Use Case**: Country-specific Top 10 lists, historical data, weekly trends 46 | - **Update Frequency**: Weekly (typically updated on Sundays) 47 | - **Limitation**: Does not include release year, making it difficult to match titles to IMDB/TMDB IDs 48 | 49 | ### 2. GraphQL API 50 | 51 | Netflix Tudum uses a GraphQL API with persisted queries for dynamic Top 10 data. 52 | 53 | #### Endpoint 54 | - **URL**: `https://pulse.prod.cloud.netflix.com/graphql` 55 | - **Method**: POST 56 | - **Content-Type**: `application/json` 57 | 58 | #### Persisted Query 59 | Netflix uses persisted queries (query ID + version) instead of sending full query strings: 60 | 61 | - **Operation Name**: `PulsePageQuery` 62 | - **Persisted Query ID**: `10ca20d3-e892-44af-b52a-f1107400a873` 63 | - **Version**: `102` 64 | 65 | #### Query Structure 66 | ```json 67 | { 68 | "operationName": "PulsePageQuery", 69 | "variables": { 70 | "withProfile": false, 71 | "url": "/top10/{country-slug}/{type}", 72 | "params": { 73 | "isWebView": false 74 | } 75 | }, 76 | "extensions": { 77 | "persistedQuery": { 78 | "id": "10ca20d3-e892-44af-b52a-f1107400a873", 79 | "version": 102 80 | } 81 | } 82 | } 83 | ``` 84 | 85 | #### URL Patterns 86 | - Country-specific: `/top10/{country-slug}/tv` or `/top10/{country-slug}` (movies) 87 | - Global: `/top10/global/tv` or `/top10/global` (movies) 88 | - Historical week: `/top10/{country-slug}/tv?week=2021-07-04` 89 | 90 | #### Country Slugs 91 | Country slugs are lowercase with hyphens (e.g., `united-kingdom`, `united-states`, `netherlands`). 92 | See `scripts/netflixTop10.js` for the complete mapping of ISO country codes to slugs. 93 | 94 | #### Response Structure 95 | The GraphQL response contains: 96 | - `pulsePage.sections[]` - Page sections 97 | - `pulsePage.sections[].entities[]` - Top 10 items (type: `PulseTop10ItemEntity`) 98 | - Each entity contains: 99 | - `top10.weeklyRank` - Rank (1-10) 100 | - `top10.weekEndDate` - Week end date 101 | - `top10.cumulativeWeeksInTop10` - Weeks in top 10 102 | - `top10Video.title` - Title 103 | - `top10Video.shortSynopsis` - Synopsis 104 | - `top10Video.releaseYear` - Release year 105 | - `top10Video.videoId` - Netflix video ID 106 | - `top10Video.maturityRating` - Content rating 107 | - `countryRanks[]` - Country-specific ranks 108 | - `artwork` - Images (logo, SDP, story art) 109 | 110 | See `docs/netflix.graphql` for the complete GraphQL schema. 111 | 112 | ## Discovery Method 113 | 114 | ### window.netflix.reactContext 115 | 116 | All information was discovered by analyzing `window.netflix.reactContext` embedded in the HTML of Netflix Tudum pages. 117 | 118 | #### Location 119 | The `reactContext` is embedded in a `'); 130 | const raw = fromMarker.slice(0, end).trim(); 131 | 132 | // Decode escape sequences 133 | const sanitized = raw.replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => 134 | String.fromCharCode(parseInt(hex, 16)) 135 | ); 136 | 137 | const context = JSON.parse(sanitized); 138 | ``` 139 | 140 | #### Contents 141 | The `reactContext` contains: 142 | - `models.graphql.data` - Complete GraphQL response data 143 | - `models.graphql.data.ROOT_QUERY` - Root query data including: 144 | - `currentCountry` - Current country information 145 | - `pulsePage` - Top 10 page data 146 | - `countries` - Array of available countries with: 147 | - `displayName` - Display name (e.g., "Hong Kong", may contain `\x20` for spaces) 148 | - `urlSegment` - URL slug for the country (e.g., "hong-kong") 149 | - Country slugs can be extracted from `pulsePage` URL keys or from the `countries` array 150 | - Top 10 entries are in `pulsePage.sections[].entities[]` 151 | 152 | Example `countries` array entry: 153 | ```javascript 154 | { 155 | "displayName": "Hong\x20Kong", 156 | "urlSegment": "hong-kong" 157 | } 158 | ``` 159 | 160 | #### GraphQL Query Discovery 161 | The persisted query ID and structure were discovered by: 162 | 1. Opening browser DevTools (F12 or right-click → Inspect) 163 | 2. Going to Network tab 164 | 3. Filtering by "graphql" string 165 | 4. Navigating to a Netflix Tudum Top 10 page 166 | 5. Finding the GraphQL request to `pulse.prod.cloud.netflix.com/graphql` 167 | 6. Inspecting the request payload to see the `persistedQuery.id` and `version` 168 | 169 | Example request payload found: 170 | ```json 171 | { 172 | "operationName": "PulsePageQuery", 173 | "variables": { 174 | "withProfile": false, 175 | "url": "/top10/united-kingdom/tv", 176 | "params": { "isWebView": false } 177 | }, 178 | "extensions": { 179 | "persistedQuery": { 180 | "id": "10ca20d3-e892-44af-b52a-f1107400a873", 181 | "version": 102 182 | } 183 | } 184 | } 185 | ``` 186 | 187 | **Note**: The persisted query ID and version may change over time. If queries start failing, check the Network tab again to find the updated ID/version. 188 | 189 | ## Usage Notes 190 | 191 | ### TSV Files 192 | - **Caching**: Recommended to cache TSV files locally (download once per week) 193 | - **File Sizes**: 194 | - `all-weeks-countries.tsv` is large (~26MB+) but manageable 195 | - `all-weeks-global.tsv` is smaller (aggregated data, no country breakdown) 196 | - `most-popular.tsv` is small (all-time aggregated data) 197 | - **Parsing**: Simple TSV format, easy to parse and index 198 | - **Reliability**: Public endpoints, stable URLs 199 | - **Limitation**: TSV files do not include release year, making it difficult to match titles to IMDB/TMDB IDs. For title matching purposes, the GraphQL API is recommended as it includes `releaseYear` in the response. 200 | 201 | ### GraphQL API 202 | - **Rate Limiting**: Unknown, but be respectful 203 | - **Authentication**: Some fields require authentication (returns `UNAUTHENTICATED` errors) 204 | - **Errors**: Non-critical errors (UNAUTHENTICATED, video not found) can be ignored 205 | - **Persisted Query**: The query ID and version may change - monitor for updates 206 | - **Week Parameter**: Historical weeks may not be supported for all countries 207 | - **Advantage**: Includes `releaseYear` field, making it easier to match titles to IMDB/TMDB IDs compared to TSV files 208 | 209 | ### Country Codes 210 | - Use ISO2 country codes (e.g., `US`, `GB`, `NL`) 211 | - Map to country slugs for GraphQL URLs (see `scripts/netflixTop10.js`) 212 | - Country slugs can be extracted from: 213 | - TSV file: `all-weeks-countries.tsv` (country names mapped to slugs) 214 | - `reactContext.countries` array (contains `displayName` and `urlSegment` for each country) 215 | 216 | ## References 217 | 218 | - Netflix Tudum Top 10: https://www.netflix.com/tudum/top10/ 219 | - TSV Data: https://www.netflix.com/tudum/top10/data/ 220 | - GraphQL Endpoint: https://pulse.prod.cloud.netflix.com/graphql 221 | 222 | -------------------------------------------------------------------------------- /vue/dist/assets/index.bc2b5f37.css: -------------------------------------------------------------------------------- 1 | *,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.my-3{margin-top:.75rem;margin-bottom:.75rem}.mx-auto{margin-left:auto;margin-right:auto}.mt-8{margin-top:2rem}.mr-2{margin-right:.5rem}.mb-7{margin-bottom:1.75rem}.mb-1{margin-bottom:.25rem}.mb-4{margin-bottom:1rem}.mb-2{margin-bottom:.5rem}.mt-4{margin-top:1rem}.mt-1{margin-top:.25rem}.mb-6{margin-bottom:1.5rem}.inline-block{display:inline-block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-\[46px\]{height:46px}.min-h-screen{min-height:100vh}.w-8{width:2rem}.w-96{width:24rem}.w-full{width:100%}.w-auto{width:auto}.cursor-pointer{cursor:pointer}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-rows-2{grid-template-rows:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.gap-2{gap:.5rem}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.self-start{align-self:flex-start}.self-center{align-self:center}.rounded-3xl{border-radius:1.5rem}.rounded-lg{border-radius:.5rem}.rounded{border-radius:.25rem}.rounded-xl{border-radius:.75rem}.rounded-r-none{border-top-right-radius:0;border-bottom-right-radius:0}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-0{border-left-width:0px}.border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity))}.bg-purple-900{--tw-bg-opacity: 1;background-color:rgb(88 28 135 / var(--tw-bg-opacity))}.bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity))}.bg-gray-800{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity))}.fill-gray-400{fill:#9ca3af}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-12{padding:3rem}.p-3{padding:.75rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.pr-3{padding-right:.75rem}.pb-4{padding-bottom:1rem}.text-center{text-align:center}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-2xl{font-size:1.5rem;line-height:2rem}.text-xs{font-size:.75rem;line-height:1rem}.font-semibold{font-weight:600}.font-medium{font-weight:500}.tracking-wide{letter-spacing:.025em}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity))}.text-purple-700{--tw-text-opacity: 1;color:rgb(126 34 206 / var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-75{opacity:.75}.opacity-30{opacity:.3}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-500{transition-duration:.5s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}html,body{--tw-bg-opacity: 1;background-color:rgb(88 28 135 / var(--tw-bg-opacity))}:root{--popper-theme-background-color: rgb(17 24 39);--popper-theme-background-color-hover: rgb(17 24 39);--popper-theme-text-color: #ffffff;--popper-theme-border-width: 0px;--popper-theme-border-style: solid;--popper-theme-border-radius: 6px;--popper-theme-padding: 6px 12px;--popper-theme-box-shadow: 0 6px 30px -6px rgba(0, 0, 0, .25)}.hover\:border-gray-900:hover{--tw-border-opacity: 1;border-color:rgb(17 24 39 / var(--tw-border-opacity))}.hover\:bg-purple-800:hover{--tw-bg-opacity: 1;background-color:rgb(107 33 168 / var(--tw-bg-opacity))}.hover\:bg-gray-900:hover{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity))}.hover\:fill-gray-500:hover{fill:#6b7280}.hover\:text-purple-600:hover{--tw-text-opacity: 1;color:rgb(147 51 234 / var(--tw-text-opacity))}.focus\:border-purple-400:focus{--tw-border-opacity: 1;border-color:rgb(192 132 252 / var(--tw-border-opacity))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}@media (min-width: 640px){.sm\:flex{display:flex}.sm\:max-w-5xl{max-width:64rem}.sm\:flex-row{flex-direction:row}.sm\:p-20{padding:5rem}}@media (min-width: 768px){.md\:mb-0{margin-bottom:0}.md\:grow{flex-grow:1}}@media (min-width: 1024px){.lg\:flex{display:flex}.lg\:p-10{padding:2.5rem}}@media (min-width: 1280px){.xl\:max-w-lg{max-width:32rem}}.inactive[data-v-bbe6dafa]{opacity:.3} 2 | -------------------------------------------------------------------------------- /src/services/netflix/fetcher.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import https from 'https'; 3 | 4 | const GRAPHQL_ENDPOINT = 'https://pulse.prod.cloud.netflix.com/graphql'; 5 | const PERSISTED_QUERY_ID = '10ca20d3-e892-44af-b52a-f1107400a873'; 6 | const PERSISTED_QUERY_VERSION = 102; 7 | 8 | const HEADERS = { 9 | 'User-Agent': 10 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 11 | 'Content-Type': 'application/json', 12 | Accept: 'application/json', 13 | }; 14 | 15 | // Map ISO country codes to Netflix display names 16 | // These are the canonical display names used by Netflix (extracted from reactContext) 17 | const ISO_TO_DISPLAY_NAME = { 18 | 'AR': 'Argentina', 19 | 'AU': 'Australia', 20 | 'AT': 'Austria', 21 | 'BS': 'Bahamas', 22 | 'BH': 'Bahrain', 23 | 'BD': 'Bangladesh', 24 | 'BE': 'Belgium', 25 | 'BO': 'Bolivia', 26 | 'BR': 'Brazil', 27 | 'BG': 'Bulgaria', 28 | 'CA': 'Canada', 29 | 'CL': 'Chile', 30 | 'CO': 'Colombia', 31 | 'CR': 'Costa Rica', 32 | 'HR': 'Croatia', 33 | 'CY': 'Cyprus', 34 | 'CZ': 'Czechia', 35 | 'DK': 'Denmark', 36 | 'DO': 'Dominican Republic', 37 | 'EC': 'Ecuador', 38 | 'EG': 'Egypt', 39 | 'SV': 'El Salvador', 40 | 'EE': 'Estonia', 41 | 'FI': 'Finland', 42 | 'FR': 'France', 43 | 'DE': 'Germany', 44 | 'GR': 'Greece', 45 | 'GP': 'Guadeloupe', 46 | 'GT': 'Guatemala', 47 | 'HN': 'Honduras', 48 | 'HK': 'Hong Kong', 49 | 'HU': 'Hungary', 50 | 'IS': 'Iceland', 51 | 'IN': 'India', 52 | 'ID': 'Indonesia', 53 | 'IE': 'Ireland', 54 | 'IL': 'Israel', 55 | 'IT': 'Italy', 56 | 'JM': 'Jamaica', 57 | 'JP': 'Japan', 58 | 'JO': 'Jordan', 59 | 'KE': 'Kenya', 60 | 'KW': 'Kuwait', 61 | 'LV': 'Latvia', 62 | 'LB': 'Lebanon', 63 | 'LT': 'Lithuania', 64 | 'LU': 'Luxembourg', 65 | 'MY': 'Malaysia', 66 | 'MV': 'Maldives', 67 | 'MT': 'Malta', 68 | 'MQ': 'Martinique', 69 | 'MU': 'Mauritius', 70 | 'MX': 'Mexico', 71 | 'MA': 'Morocco', 72 | 'NL': 'Netherlands', 73 | 'NC': 'New Caledonia', 74 | 'NZ': 'New Zealand', 75 | 'NI': 'Nicaragua', 76 | 'NG': 'Nigeria', 77 | 'NO': 'Norway', 78 | 'OM': 'Oman', 79 | 'PK': 'Pakistan', 80 | 'PA': 'Panama', 81 | 'PY': 'Paraguay', 82 | 'PE': 'Peru', 83 | 'PH': 'Philippines', 84 | 'PL': 'Poland', 85 | 'PT': 'Portugal', 86 | 'QA': 'Qatar', 87 | 'RE': 'Réunion', 88 | 'RO': 'Romania', 89 | 'RU': 'Russia', 90 | 'SA': 'Saudi Arabia', 91 | 'RS': 'Serbia', 92 | 'SG': 'Singapore', 93 | 'SK': 'Slovakia', 94 | 'SI': 'Slovenia', 95 | 'ZA': 'South Africa', 96 | 'KR': 'South Korea', 97 | 'ES': 'Spain', 98 | 'LK': 'Sri Lanka', 99 | 'SE': 'Sweden', 100 | 'CH': 'Switzerland', 101 | 'TW': 'Taiwan', 102 | 'TH': 'Thailand', 103 | 'TT': 'Trinidad and Tobago', 104 | 'TR': 'Türkiye', 105 | 'UA': 'Ukraine', 106 | 'AE': 'United Arab Emirates', 107 | 'GB': 'United Kingdom', 108 | 'US': 'United States', 109 | 'UY': 'Uruguay', 110 | 'VE': 'Venezuela', 111 | 'VN': 'Vietnam', 112 | }; 113 | 114 | /** 115 | * Normalize displayName to urlSegment format used by Netflix 116 | * Converts to lowercase, replaces spaces with hyphens, and removes accents 117 | */ 118 | function normalizeToUrlSegment(displayName) { 119 | return displayName 120 | .toLowerCase() 121 | .normalize('NFD') // Decompose accented characters 122 | .replace(/[\u0300-\u036f]/g, '') // Remove combining diacritical marks 123 | .replace(/\s+/g, '-'); // Replace spaces with hyphens 124 | } 125 | 126 | // Build COUNTRY_SLUGS mapping from ISO codes to urlSegments 127 | // Generated from ISO_TO_DISPLAY_NAME by normalizing display names 128 | const COUNTRY_SLUGS = Object.fromEntries( 129 | Object.entries(ISO_TO_DISPLAY_NAME).map(([iso, displayName]) => [ 130 | iso, 131 | normalizeToUrlSegment(displayName), 132 | ]) 133 | ); 134 | 135 | function getCountrySlug(countryCode) { 136 | const code = countryCode.toUpperCase(); 137 | const slug = COUNTRY_SLUGS[code]; 138 | 139 | if (!slug) { 140 | // Fallback to lowercase, but warn that it might not work 141 | // Most countries need explicit mapping - check https://www.netflix.com/tudum/top10/ 142 | console.warn(`Warning: Country code "${code}" not mapped. Using "${code.toLowerCase()}" as slug. This may not work.`); 143 | return code.toLowerCase(); 144 | } 145 | 146 | return slug; 147 | } 148 | 149 | function resolveTypeSegment(type) { 150 | const key = type?.trim().toLowerCase(); 151 | if (key === 'shows') return 'tv'; 152 | if (key === 'movies') return ''; 153 | throw new Error('type must be "shows" or "movies"'); 154 | } 155 | 156 | function buildGraphQLQuery(countrySlug, typeSegment, week = null) { 157 | let url = `/top10/${countrySlug}${typeSegment ? `/${typeSegment}` : ''}`; 158 | 159 | // Add week parameter if specified (format: YYYY-MM-DD) 160 | // If not specified, defaults to latest week 161 | if (week) { 162 | url += `?week=${week}`; 163 | } 164 | 165 | return { 166 | operationName: 'PulsePageQuery', 167 | variables: { 168 | withProfile: false, 169 | url, 170 | params: { isWebView: false }, 171 | }, 172 | extensions: { 173 | persistedQuery: { 174 | id: PERSISTED_QUERY_ID, 175 | version: PERSISTED_QUERY_VERSION, 176 | }, 177 | }, 178 | }; 179 | } 180 | 181 | async function fetchGraphQL(countrySlug, typeSegment, options = {}) { 182 | const query = buildGraphQLQuery(countrySlug, typeSegment, options.week); 183 | 184 | const axiosOptions = { 185 | method: 'POST', 186 | url: GRAPHQL_ENDPOINT, 187 | headers: HEADERS, 188 | data: query, 189 | timeout: options.timeout ?? 15000, 190 | }; 191 | 192 | if (options.allowInsecureTLS) { 193 | axiosOptions.httpsAgent = new https.Agent({ rejectUnauthorized: false }); 194 | } 195 | 196 | const { data } = await axios(axiosOptions); 197 | 198 | // Check for critical errors (non-authentication related, non-video-not-found) 199 | if (data.errors) { 200 | const criticalErrors = data.errors.filter( 201 | e => !e.message.includes('UNAUTHENTICATED') && 202 | !e.message.includes('Setting Non-null field') && 203 | !e.message.includes('not found') 204 | ); 205 | if (criticalErrors.length > 0) { 206 | const errorMessages = criticalErrors.map(e => e.message).join('; '); 207 | throw new Error(`GraphQL errors: ${errorMessages}`); 208 | } 209 | } 210 | 211 | if (!data.data) { 212 | throw new Error('No data returned from GraphQL API'); 213 | } 214 | 215 | return data.data; 216 | } 217 | 218 | function parseTop10Entries(graphqlData) { 219 | const pulsePage = graphqlData?.pulsePage; 220 | if (!pulsePage?.sections) { 221 | throw new Error('GraphQL response did not contain pulsePage sections'); 222 | } 223 | 224 | const entries = []; 225 | const seen = new Set(); 226 | 227 | for (const section of pulsePage.sections) { 228 | if (section.__typename !== 'PulseEntitiesSection' || !section.entities) { 229 | continue; 230 | } 231 | 232 | // Only process top-10-card-list or top-10-table sections 233 | // If guid is empty/null, check if it contains top-10 entities 234 | const isTop10Section = section.guid 235 | ? ['top-10-card-list', 'top-10-table'].includes(section.guid) 236 | : section.entities.some(e => e.__typename === 'PulseTop10ItemEntity'); 237 | 238 | if (!isTop10Section) { 239 | continue; 240 | } 241 | 242 | for (const entity of section.entities) { 243 | if (entity.__typename !== 'PulseTop10ItemEntity') { 244 | continue; 245 | } 246 | 247 | const top10 = entity?.top10; 248 | const video = entity?.top10Video; 249 | if (!top10 || !video?.videoId) { 250 | continue; 251 | } 252 | 253 | // Deduplicate by videoId (entries appear in both card-list and table) 254 | if (seen.has(video.videoId)) { 255 | continue; 256 | } 257 | 258 | const rank = top10.weeklyRank ?? null; 259 | if (rank == null) { 260 | continue; 261 | } 262 | 263 | seen.add(video.videoId); 264 | 265 | // Use parentShow.title for shows/seasons to get the show title without "Season X" suffix 266 | // For movies, parentShow will be null, so fall back to video.title 267 | const title = video.parentShow?.title ?? video.title ?? null; 268 | 269 | entries.push({ 270 | rank, 271 | title, 272 | synopsis: video.shortSynopsis ?? null, 273 | releaseYear: video.releaseYear ?? null, 274 | weeksInTop10: top10.cumulativeWeeksInTop10 ?? null, 275 | weekEndDate: top10.weekEndDate ?? null, 276 | runtimeHours: top10.runtime ?? null, 277 | videoId: video.videoId ?? null, 278 | maturityRating: video.maturityRating ?? null, 279 | artwork: { 280 | logo: entity?.artwork?.logoArt?.urlsSized?.[0]?.url ?? null, 281 | sdp: entity?.artwork?.sdpArt?.urlsSized?.[0]?.url ?? null, 282 | story: entity?.artwork?.storyArt?.urlsSized?.[0]?.url ?? null, 283 | }, 284 | }); 285 | } 286 | } 287 | 288 | return entries.sort((a, b) => a.rank - b.rank); 289 | } 290 | 291 | /** 292 | * Fetches Netflix Top 10 for a given country and type. 293 | * 294 | * @param {string} countryCode - ISO country code (e.g., "NL", "US", "GB") 295 | * @param {string} type - Content type: "shows" or "movies" 296 | * @param {object} options - Optional parameters 297 | * @param {string} options.week - Optional week date (YYYY-MM-DD format). If omitted, returns latest week. 298 | * @param {boolean} options.allowInsecureTLS - Allow insecure TLS (for testing) 299 | * @param {number} options.timeout - Request timeout in ms (default: 15000) 300 | * @returns {Promise} Top 10 data with results array 301 | * 302 | * Note: The persisted query ID stays the same across all weeks. The week parameter 303 | * is passed in the URL query string. If not specified, defaults to the latest week. 304 | */ 305 | export async function fetchNetflixTop10(countryCode, type, options = {}) { 306 | if (!countryCode) { 307 | throw new Error('countryCode is required (e.g., "NL", "US", "GB")'); 308 | } 309 | 310 | if (!type) { 311 | throw new Error('type is required ("shows" or "movies")'); 312 | } 313 | 314 | const countrySlug = getCountrySlug(countryCode); 315 | const typeSegment = resolveTypeSegment(type); 316 | const graphqlData = await fetchGraphQL(countrySlug, typeSegment, options); 317 | 318 | const entries = parseTop10Entries(graphqlData); 319 | 320 | return { 321 | countryCode: countryCode.toUpperCase(), 322 | type: typeSegment || 'movies', 323 | weekEndDate: entries[0]?.weekEndDate ?? null, 324 | results: entries, 325 | }; 326 | } 327 | 328 | -------------------------------------------------------------------------------- /src/services/netflix/resolver.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { fetchNetflixTop10 } from './fetcher.js'; 3 | import { loadResolutionCache, saveResolutionCache, loadNetflixTop10Cache, saveNetflixTop10Cache } from '../../utils/cache.js'; 4 | import { fetchCinemetaMeta, getBasicMeta } from '../cinemeta.js'; 5 | 6 | // Add delay between JustWatch requests to avoid rate limiting 7 | const JUSTWATCH_DELAY_MS = 200; 8 | 9 | const JUSTWATCH_GRAPHQL_ENDPOINT = 'https://apis.justwatch.com/graphql'; 10 | 11 | // In-memory cache for resolved titles 12 | let resolutionCache = loadResolutionCache(); 13 | 14 | // In-memory cache for Netflix Top 10 catalogs 15 | // Structure: { catalogs: { "US:movies": [...], "NL:shows": [...] }, timestamp: Date.now() } 16 | const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours 17 | let catalogCacheData = loadNetflixTop10Cache(CACHE_DURATION_MS); 18 | let catalogCache = catalogCacheData.catalogs; 19 | 20 | /** 21 | * Generate cache key for a title 22 | */ 23 | function getCacheKey(title, releaseYear, type) { 24 | return `${type}:${title}:${releaseYear}`.toLowerCase(); 25 | } 26 | 27 | /** 28 | * Search JustWatch for a title by name, year, and optionally Netflix provider 29 | */ 30 | async function searchJustWatchByTitle(title, releaseYear, country = 'US', type = 'MOVIE', withNetflixFilter = true) { 31 | if (!title || !releaseYear) { 32 | return null; 33 | } 34 | 35 | const filter = { 36 | searchQuery: title, 37 | objectTypes: [type], 38 | }; 39 | 40 | // Optionally filter by Netflix provider 41 | if (withNetflixFilter) { 42 | filter.packages = ['nfx']; 43 | } 44 | 45 | const query = { 46 | operationName: 'SearchTitles', 47 | variables: { 48 | country, 49 | language: 'en', 50 | first: 10, 51 | filter, 52 | }, 53 | query: ` 54 | query SearchTitles( 55 | $country: Country! 56 | $language: Language! 57 | $first: Int! 58 | $filter: TitleFilter 59 | ) { 60 | popularTitles( 61 | country: $country 62 | filter: $filter 63 | first: $first 64 | sortBy: POPULAR 65 | ) { 66 | edges { 67 | node { 68 | id 69 | objectId 70 | objectType 71 | content(country: $country, language: $language) { 72 | title 73 | originalReleaseYear 74 | externalIds { 75 | imdbId 76 | } 77 | } 78 | } 79 | } 80 | } 81 | } 82 | `, 83 | }; 84 | 85 | try { 86 | const response = await axios.post(JUSTWATCH_GRAPHQL_ENDPOINT, query, { 87 | headers: { 88 | 'Content-Type': 'application/json', 89 | }, 90 | timeout: 10000, 91 | }); 92 | 93 | if (response.data.errors) { 94 | return null; 95 | } 96 | 97 | const edges = response.data.data?.popularTitles?.edges || []; 98 | 99 | if (edges.length === 0) { 100 | return null; 101 | } 102 | 103 | // Try to find best match: 104 | // 1. Exact title + exact year 105 | // 2. Exact title + year within ±1 106 | // 3. Title contains search term + year match (±1) 107 | // 4. First result with year match (±1) 108 | // 5. First result (fallback) 109 | 110 | const normalizedSearchTitle = title.toLowerCase().trim(); 111 | 112 | // Helper to normalize titles for comparison (remove punctuation, extra spaces) 113 | const normalizeForMatch = (str) => { 114 | return str.toLowerCase() 115 | .replace(/[^\w\s]/g, ' ') // Replace punctuation with spaces 116 | .replace(/\s+/g, ' ') // Normalize spaces 117 | .trim(); 118 | }; 119 | 120 | const normalizedSearch = normalizeForMatch(title); 121 | 122 | // 1. Exact title + exact year 123 | const exactMatch = edges.find((edge) => { 124 | const content = edge.node.content; 125 | const edgeTitle = content?.title?.toLowerCase().trim(); 126 | const edgeYear = content?.originalReleaseYear; 127 | return edgeTitle === normalizedSearchTitle && edgeYear === releaseYear; 128 | }); 129 | 130 | if (exactMatch) return exactMatch; 131 | 132 | // 2. Normalized title match + exact year 133 | const normalizedExactMatch = edges.find((edge) => { 134 | const content = edge.node.content; 135 | const edgeTitle = normalizeForMatch(content?.title || ''); 136 | const edgeYear = content?.originalReleaseYear; 137 | return edgeTitle === normalizedSearch && edgeYear === releaseYear; 138 | }); 139 | 140 | if (normalizedExactMatch) return normalizedExactMatch; 141 | 142 | // 3. Exact title + year within ±1 143 | const fuzzyYearMatch = edges.find((edge) => { 144 | const content = edge.node.content; 145 | const edgeTitle = content?.title?.toLowerCase().trim(); 146 | const edgeYear = content?.originalReleaseYear; 147 | const yearDiff = Math.abs(edgeYear - releaseYear); 148 | return edgeTitle === normalizedSearchTitle && yearDiff <= 1; 149 | }); 150 | 151 | if (fuzzyYearMatch) return fuzzyYearMatch; 152 | 153 | // 4. Title contains search term + year match (±1) 154 | const containsMatch = edges.find((edge) => { 155 | const content = edge.node.content; 156 | const edgeTitle = normalizeForMatch(content?.title || ''); 157 | const edgeYear = content?.originalReleaseYear; 158 | const yearDiff = Math.abs(edgeYear - releaseYear); 159 | return (edgeTitle.includes(normalizedSearch) || normalizedSearch.includes(edgeTitle)) 160 | && yearDiff <= 1; 161 | }); 162 | 163 | if (containsMatch) return containsMatch; 164 | 165 | // 5. First result with year match (±1) 166 | const yearMatch = edges.find((edge) => { 167 | const content = edge.node.content; 168 | const edgeYear = content?.originalReleaseYear; 169 | return Math.abs(edgeYear - releaseYear) <= 1; 170 | }); 171 | 172 | if (yearMatch) return yearMatch; 173 | 174 | // 6. Return first result as fallback 175 | return edges[0]; 176 | } catch (error) { 177 | // Don't log 404s or network errors as errors, they're expected 178 | if (error.response?.status !== 404 && error.code !== 'ECONNABORTED') { 179 | console.error('Error searching JustWatch:', error.message); 180 | } 181 | return null; 182 | } 183 | } 184 | 185 | /** 186 | * Generate title variations for better matching 187 | */ 188 | function generateTitleVariations(title) { 189 | const variations = [title]; 190 | 191 | // Handle "Vol. 1", "Volume 1", "Part 1" variations (for movies) 192 | const volPatterns = [ 193 | /:\s*Vol\.\s*\d+/i, 194 | /:\s*Volume\s*\d+/i, 195 | /:\s*Part\s*\d+/i, 196 | /\s*Vol\.\s*\d+/i, 197 | /\s*Volume\s*\d+/i, 198 | /\s*Part\s*\d+/i, 199 | ]; 200 | 201 | for (const pattern of volPatterns) { 202 | if (pattern.test(title)) { 203 | const withoutVol = title.replace(pattern, '').trim(); 204 | if (withoutVol && !variations.includes(withoutVol)) { 205 | variations.push(withoutVol); 206 | } 207 | } 208 | } 209 | 210 | // Remove "The" prefix for matching 211 | if (title.match(/^the\s+/i)) { 212 | const withoutThe = title.replace(/^the\s+/i, '').trim(); 213 | if (withoutThe && !variations.includes(withoutThe)) { 214 | variations.push(withoutThe); 215 | } 216 | } 217 | 218 | return variations; 219 | } 220 | 221 | /** 222 | * Resolve a Netflix title to IMDB ID and metadata 223 | */ 224 | async function resolveTitle(title, releaseYear, type, country = 'US') { 225 | if (!title || !releaseYear) { 226 | return null; 227 | } 228 | 229 | // Check cache first 230 | const cacheKey = getCacheKey(title, releaseYear, type); 231 | if (resolutionCache[cacheKey]) { 232 | return resolutionCache[cacheKey]; 233 | } 234 | 235 | const justwatchType = type === 'shows' ? 'SHOW' : 'MOVIE'; 236 | 237 | // Generate title variations 238 | const titleVariations = generateTitleVariations(title); 239 | 240 | // Try each variation, first with Netflix filter, then without 241 | let justwatchResult = null; 242 | 243 | for (const searchTitle of titleVariations) { 244 | // Try with Netflix provider filter first 245 | justwatchResult = await searchJustWatchByTitle( 246 | searchTitle, 247 | releaseYear, 248 | country, 249 | justwatchType, 250 | true // withNetflixFilter 251 | ); 252 | 253 | if (justwatchResult) { 254 | break; 255 | } 256 | 257 | // If not found with Netflix filter, try without (movie might not be on Netflix in this country) 258 | justwatchResult = await searchJustWatchByTitle( 259 | searchTitle, 260 | releaseYear, 261 | country, 262 | justwatchType, 263 | false // withoutNetflixFilter 264 | ); 265 | 266 | if (justwatchResult) { 267 | break; 268 | } 269 | } 270 | 271 | if (!justwatchResult) { 272 | resolutionCache[cacheKey] = null; 273 | saveResolutionCache(resolutionCache); 274 | return null; 275 | } 276 | 277 | const content = justwatchResult.node.content; 278 | const imdbId = content?.externalIds?.imdbId; 279 | 280 | if (!imdbId) { 281 | resolutionCache[cacheKey] = null; 282 | saveResolutionCache(resolutionCache); 283 | return null; 284 | } 285 | 286 | const result = { 287 | imdbId, 288 | title: content.title, 289 | year: content.originalReleaseYear, 290 | }; 291 | 292 | // Cache the result 293 | resolutionCache[cacheKey] = result; 294 | saveResolutionCache(resolutionCache); 295 | 296 | return result; 297 | } 298 | 299 | /** 300 | * Get cache key for Netflix Top 10 catalog 301 | */ 302 | function getCatalogCacheKey(countryCode, type) { 303 | return `${countryCode.toUpperCase()}:${type}`; 304 | } 305 | 306 | /** 307 | * Get Netflix Top 10 for a country and resolve titles to Stremio metadata 308 | * Uses in-memory cache with file persistence (24-hour cache duration) 309 | */ 310 | export async function getNetflixTop10Catalog(countryCode, type, options = {}) { 311 | const cacheKey = getCatalogCacheKey(countryCode, type); 312 | 313 | // Check if cache is expired and reload if needed 314 | const now = Date.now(); 315 | if (catalogCacheData.timestamp && (now - catalogCacheData.timestamp) >= CACHE_DURATION_MS) { 316 | console.log('Netflix Top 10 catalog cache expired, reloading from disk...'); 317 | catalogCacheData = loadNetflixTop10Cache(CACHE_DURATION_MS); 318 | catalogCache = catalogCacheData.catalogs; 319 | } 320 | 321 | // Check in-memory cache first 322 | if (catalogCache[cacheKey]) { 323 | console.log(`Netflix Top 10 cache hit: ${cacheKey}`); 324 | return catalogCache[cacheKey]; 325 | } 326 | 327 | console.log(`Netflix Top 10 cache miss: ${cacheKey}, fetching...`); 328 | 329 | try { 330 | // Fetch Netflix Top 10 331 | const netflixData = await fetchNetflixTop10(countryCode, type, options); 332 | 333 | if (!netflixData || !netflixData.results || netflixData.results.length === 0) { 334 | // Cache empty result to avoid repeated fetches 335 | catalogCache[cacheKey] = []; 336 | catalogCacheData.catalogs = catalogCache; 337 | catalogCacheData.timestamp = Date.now(); 338 | saveNetflixTop10Cache(catalogCache); 339 | return []; 340 | } 341 | 342 | const stremioType = type === 'shows' ? 'series' : 'movie'; 343 | const justwatchType = type === 'shows' ? 'SHOW' : 'MOVIE'; 344 | 345 | // Resolve titles to IMDB IDs and fetch metadata 346 | const metas = []; 347 | for (let i = 0; i < netflixData.results.length; i++) { 348 | const item = netflixData.results[i]; 349 | 350 | // Add delay between JustWatch requests 351 | if (i > 0) { 352 | await new Promise(resolve => setTimeout(resolve, JUSTWATCH_DELAY_MS)); 353 | } 354 | 355 | const resolution = await resolveTitle( 356 | item.title, 357 | item.releaseYear, 358 | type, 359 | countryCode 360 | ); 361 | 362 | if (!resolution || !resolution.imdbId) { 363 | continue; 364 | } 365 | 366 | const imdbId = resolution.imdbId; 367 | 368 | // Fetch metadata from cinemeta 369 | const justwatchType = type === 'shows' ? 'SHOW' : 'MOVIE'; 370 | let meta = await fetchCinemetaMeta(imdbId, justwatchType, item.title); 371 | 372 | // Fallback: return basic metadata 373 | if (!meta) { 374 | meta = getBasicMeta(imdbId, item.title, stremioType); 375 | } 376 | 377 | if (meta) { 378 | metas.push(meta); 379 | } 380 | } 381 | 382 | // Cache the resolved metas 383 | catalogCache[cacheKey] = metas; 384 | catalogCacheData.catalogs = catalogCache; 385 | catalogCacheData.timestamp = Date.now(); 386 | saveNetflixTop10Cache(catalogCache); 387 | console.log(`Netflix Top 10 cached: ${cacheKey} (${metas.length} items)`); 388 | 389 | // Return resolved metas 390 | return metas; 391 | } catch (error) { 392 | console.error(`Error getting Netflix Top 10 for ${countryCode} ${type}:`, error.message); 393 | // Cache empty result on error to avoid repeated failed fetches 394 | catalogCache[cacheKey] = []; 395 | catalogCacheData.catalogs = catalogCache; 396 | catalogCacheData.timestamp = Date.now(); 397 | saveNetflixTop10Cache(catalogCache); 398 | return []; 399 | } 400 | } 401 | 402 | /** 403 | * Get global Netflix Top 10 (aggregated across all countries) 404 | * For now, we'll use US as the global source 405 | */ 406 | export async function getNetflixTop10Global(type, options = {}) { 407 | return getNetflixTop10Catalog('US', type, options); 408 | } 409 | 410 | -------------------------------------------------------------------------------- /vue/src/regions-to-countries.json: -------------------------------------------------------------------------------- 1 | { 2 | "Europe/Andorra": "Andorra", 3 | "Asia/Dubai": "United Arab Emirates", 4 | "Asia/Kabul": "Afghanistan", 5 | "Europe/Tirane": "Albania", 6 | "Asia/Yerevan": "Armenia", 7 | "Antarctica/Casey": "Antarctica", 8 | "Antarctica/Davis": "Antarctica", 9 | "Antarctica/Mawson": "Antarctica", 10 | "Antarctica/Palmer": "Antarctica", 11 | "Antarctica/Rothera": "Antarctica", 12 | "Antarctica/Troll": "Antarctica", 13 | "Antarctica/Vostok": "Antarctica", 14 | "America/Argentina/Buenos_Aires": "Argentina", 15 | "America/Argentina/Cordoba": "Argentina", 16 | "America/Argentina/Salta": "Argentina", 17 | "America/Argentina/Jujuy": "Argentina", 18 | "America/Argentina/Tucuman": "Argentina", 19 | "America/Argentina/Catamarca": "Argentina", 20 | "America/Argentina/La_Rioja": "Argentina", 21 | "America/Argentina/San_Juan": "Argentina", 22 | "America/Argentina/Mendoza": "Argentina", 23 | "America/Argentina/San_Luis": "Argentina", 24 | "America/Argentina/Rio_Gallegos": "Argentina", 25 | "America/Argentina/Ushuaia": "Argentina", 26 | "Pacific/Pago_Pago": "Samoa (American)", 27 | "Europe/Vienna": "Austria", 28 | "Australia/Lord_Howe": "Australia", 29 | "Antarctica/Macquarie": "Australia", 30 | "Australia/Hobart": "Australia", 31 | "Australia/Melbourne": "Australia", 32 | "Australia/Sydney": "Australia", 33 | "Australia/Broken_Hill": "Australia", 34 | "Australia/Brisbane": "Australia", 35 | "Australia/Lindeman": "Australia", 36 | "Australia/Adelaide": "Australia", 37 | "Australia/Darwin": "Australia", 38 | "Australia/Perth": "Australia", 39 | "Australia/Eucla": "Australia", 40 | "Asia/Baku": "Azerbaijan", 41 | "America/Barbados": "Barbados", 42 | "Asia/Dhaka": "Bangladesh", 43 | "Europe/Brussels": "Belgium", 44 | "Europe/Sofia": "Bulgaria", 45 | "Atlantic/Bermuda": "Bermuda", 46 | "Asia/Brunei": "Brunei", 47 | "America/La_Paz": "Bolivia", 48 | "America/Noronha": "Brazil", 49 | "America/Belem": "Brazil", 50 | "America/Fortaleza": "Brazil", 51 | "America/Recife": "Brazil", 52 | "America/Araguaina": "Brazil", 53 | "America/Maceio": "Brazil", 54 | "America/Bahia": "Brazil", 55 | "America/Sao_Paulo": "Brazil", 56 | "America/Campo_Grande": "Brazil", 57 | "America/Cuiaba": "Brazil", 58 | "America/Santarem": "Brazil", 59 | "America/Porto_Velho": "Brazil", 60 | "America/Boa_Vista": "Brazil", 61 | "America/Manaus": "Brazil", 62 | "America/Eirunepe": "Brazil", 63 | "America/Rio_Branco": "Brazil", 64 | "Asia/Thimphu": "Bhutan", 65 | "Europe/Minsk": "Belarus", 66 | "America/Belize": "Belize", 67 | "America/St_Johns": "Canada", 68 | "America/Halifax": "Canada", 69 | "America/Glace_Bay": "Canada", 70 | "America/Moncton": "Canada", 71 | "America/Goose_Bay": "Canada", 72 | "America/Toronto": "Canada", 73 | "America/Nipigon": "Canada", 74 | "America/Thunder_Bay": "Canada", 75 | "America/Iqaluit": "Canada", 76 | "America/Pangnirtung": "Canada", 77 | "America/Winnipeg": "Canada", 78 | "America/Rainy_River": "Canada", 79 | "America/Resolute": "Canada", 80 | "America/Rankin_Inlet": "Canada", 81 | "America/Regina": "Canada", 82 | "America/Swift_Current": "Canada", 83 | "America/Edmonton": "Canada", 84 | "America/Cambridge_Bay": "Canada", 85 | "America/Yellowknife": "Canada", 86 | "America/Inuvik": "Canada", 87 | "America/Dawson_Creek": "Canada", 88 | "America/Fort_Nelson": "Canada", 89 | "America/Whitehorse": "Canada", 90 | "America/Dawson": "Canada", 91 | "America/Vancouver": "Canada", 92 | "Indian/Cocos": "Cocos (Keeling) Islands", 93 | "Europe/Zurich": "Switzerland", 94 | "Africa/Abidjan": "Côte d'Ivoire", 95 | "Pacific/Rarotonga": "Cook Islands", 96 | "America/Santiago": "Chile", 97 | "America/Punta_Arenas": "Chile", 98 | "Pacific/Easter": "Chile", 99 | "Asia/Shanghai": "China", 100 | "Asia/Urumqi": "China", 101 | "America/Bogota": "Colombia", 102 | "America/Costa_Rica": "Costa Rica", 103 | "America/Havana": "Cuba", 104 | "Atlantic/Cape_Verde": "Cape Verde", 105 | "Indian/Christmas": "Christmas Island", 106 | "Asia/Nicosia": "Cyprus", 107 | "Asia/Famagusta": "Cyprus", 108 | "Europe/Prague": "Czech Republic", 109 | "Europe/Berlin": "Germany", 110 | "Europe/Copenhagen": "Denmark", 111 | "America/Santo_Domingo": "Dominican Republic", 112 | "Africa/Algiers": "Algeria", 113 | "America/Guayaquil": "Ecuador", 114 | "Pacific/Galapagos": "Ecuador", 115 | "Europe/Tallinn": "Estonia", 116 | "Africa/Cairo": "Egypt", 117 | "Africa/El_Aaiun": "Western Sahara", 118 | "Europe/Madrid": "Spain", 119 | "Africa/Ceuta": "Spain", 120 | "Atlantic/Canary": "Spain", 121 | "Europe/Helsinki": "Finland", 122 | "Pacific/Fiji": "Fiji", 123 | "Atlantic/Stanley": "Falkland Islands", 124 | "Pacific/Chuuk": "Micronesia", 125 | "Pacific/Pohnpei": "Micronesia", 126 | "Pacific/Kosrae": "Micronesia", 127 | "Atlantic/Faroe": "Faroe Islands", 128 | "Europe/Paris": "France", 129 | "Europe/London": "Britain (UK)", 130 | "Asia/Tbilisi": "Georgia", 131 | "America/Cayenne": "French Guiana", 132 | "Europe/Gibraltar": "Gibraltar", 133 | "America/Nuuk": "Greenland", 134 | "America/Danmarkshavn": "Greenland", 135 | "America/Scoresbysund": "Greenland", 136 | "America/Thule": "Greenland", 137 | "Europe/Athens": "Greece", 138 | "Atlantic/South_Georgia": "South Georgia & the South Sandwich Islands", 139 | "America/Guatemala": "Guatemala", 140 | "Pacific/Guam": "Guam", 141 | "Africa/Bissau": "Guinea-Bissau", 142 | "America/Guyana": "Guyana", 143 | "Asia/Hong_Kong": "Hong Kong", 144 | "America/Tegucigalpa": "Honduras", 145 | "America/Port-au-Prince": "Haiti", 146 | "Europe/Budapest": "Hungary", 147 | "Asia/Jakarta": "Indonesia", 148 | "Asia/Pontianak": "Indonesia", 149 | "Asia/Makassar": "Indonesia", 150 | "Asia/Jayapura": "Indonesia", 151 | "Europe/Dublin": "Ireland", 152 | "Asia/Jerusalem": "Israel", 153 | "Asia/Kolkata": "India", 154 | "Indian/Chagos": "British Indian Ocean Territory", 155 | "Asia/Baghdad": "Iraq", 156 | "Asia/Tehran": "Iran", 157 | "Atlantic/Reykjavik": "Iceland", 158 | "Europe/Rome": "Italy", 159 | "America/Jamaica": "Jamaica", 160 | "Asia/Amman": "Jordan", 161 | "Asia/Tokyo": "Japan", 162 | "Africa/Nairobi": "Kenya", 163 | "Asia/Bishkek": "Kyrgyzstan", 164 | "Pacific/Tarawa": "Kiribati", 165 | "Pacific/Kanton": "Kiribati", 166 | "Pacific/Kiritimati": "Kiribati", 167 | "Asia/Pyongyang": "Korea (North)", 168 | "Asia/Seoul": "Korea (South)", 169 | "Asia/Almaty": "Kazakhstan", 170 | "Asia/Qyzylorda": "Kazakhstan", 171 | "Asia/Qostanay": "Kazakhstan", 172 | "Asia/Aqtobe": "Kazakhstan", 173 | "Asia/Aqtau": "Kazakhstan", 174 | "Asia/Atyrau": "Kazakhstan", 175 | "Asia/Oral": "Kazakhstan", 176 | "Asia/Beirut": "Lebanon", 177 | "Asia/Colombo": "Sri Lanka", 178 | "Africa/Monrovia": "Liberia", 179 | "Europe/Vilnius": "Lithuania", 180 | "Europe/Luxembourg": "Luxembourg", 181 | "Europe/Riga": "Latvia", 182 | "Africa/Tripoli": "Libya", 183 | "Africa/Casablanca": "Morocco", 184 | "Europe/Monaco": "Monaco", 185 | "Europe/Chisinau": "Moldova", 186 | "Pacific/Majuro": "Marshall Islands", 187 | "Pacific/Kwajalein": "Marshall Islands", 188 | "Asia/Yangon": "Myanmar (Burma)", 189 | "Asia/Ulaanbaatar": "Mongolia", 190 | "Asia/Hovd": "Mongolia", 191 | "Asia/Choibalsan": "Mongolia", 192 | "Asia/Macau": "Macau", 193 | "America/Martinique": "Martinique", 194 | "Europe/Malta": "Malta", 195 | "Indian/Mauritius": "Mauritius", 196 | "Indian/Maldives": "Maldives", 197 | "America/Mexico_City": "Mexico", 198 | "America/Cancun": "Mexico", 199 | "America/Merida": "Mexico", 200 | "America/Monterrey": "Mexico", 201 | "America/Matamoros": "Mexico", 202 | "America/Mazatlan": "Mexico", 203 | "America/Chihuahua": "Mexico", 204 | "America/Ojinaga": "Mexico", 205 | "America/Hermosillo": "Mexico", 206 | "America/Tijuana": "Mexico", 207 | "America/Bahia_Banderas": "Mexico", 208 | "Asia/Kuala_Lumpur": "Malaysia", 209 | "Asia/Kuching": "Malaysia", 210 | "Africa/Maputo": "Mozambique", 211 | "Africa/Windhoek": "Namibia", 212 | "Pacific/Noumea": "New Caledonia", 213 | "Pacific/Norfolk": "Norfolk Island", 214 | "Africa/Lagos": "Nigeria", 215 | "America/Managua": "Nicaragua", 216 | "Europe/Amsterdam": "Netherlands", 217 | "Europe/Oslo": "Norway", 218 | "Asia/Kathmandu": "Nepal", 219 | "Pacific/Nauru": "Nauru", 220 | "Pacific/Niue": "Niue", 221 | "Pacific/Auckland": "New Zealand", 222 | "Pacific/Chatham": "New Zealand", 223 | "America/Panama": "Panama", 224 | "America/Lima": "Peru", 225 | "Pacific/Tahiti": "French Polynesia", 226 | "Pacific/Marquesas": "French Polynesia", 227 | "Pacific/Gambier": "French Polynesia", 228 | "Pacific/Port_Moresby": "Papua New Guinea", 229 | "Pacific/Bougainville": "Papua New Guinea", 230 | "Asia/Manila": "Philippines", 231 | "Asia/Karachi": "Pakistan", 232 | "Europe/Warsaw": "Poland", 233 | "America/Miquelon": "St Pierre & Miquelon", 234 | "Pacific/Pitcairn": "Pitcairn", 235 | "America/Puerto_Rico": "Puerto Rico", 236 | "Asia/Gaza": "Palestine", 237 | "Asia/Hebron": "Palestine", 238 | "Europe/Lisbon": "Portugal", 239 | "Atlantic/Madeira": "Portugal", 240 | "Atlantic/Azores": "Portugal", 241 | "Pacific/Palau": "Palau", 242 | "America/Asuncion": "Paraguay", 243 | "Asia/Qatar": "Qatar", 244 | "Indian/Reunion": "Réunion", 245 | "Europe/Bucharest": "Romania", 246 | "Europe/Belgrade": "Serbia", 247 | "Europe/Kaliningrad": "Russia", 248 | "Europe/Moscow": "Russia", 249 | "Europe/Simferopol": "Russia", 250 | "Europe/Kirov": "Russia", 251 | "Europe/Volgograd": "Russia", 252 | "Europe/Astrakhan": "Russia", 253 | "Europe/Saratov": "Russia", 254 | "Europe/Ulyanovsk": "Russia", 255 | "Europe/Samara": "Russia", 256 | "Asia/Yekaterinburg": "Russia", 257 | "Asia/Omsk": "Russia", 258 | "Asia/Novosibirsk": "Russia", 259 | "Asia/Barnaul": "Russia", 260 | "Asia/Tomsk": "Russia", 261 | "Asia/Novokuznetsk": "Russia", 262 | "Asia/Krasnoyarsk": "Russia", 263 | "Asia/Irkutsk": "Russia", 264 | "Asia/Chita": "Russia", 265 | "Asia/Yakutsk": "Russia", 266 | "Asia/Khandyga": "Russia", 267 | "Asia/Vladivostok": "Russia", 268 | "Asia/Ust-Nera": "Russia", 269 | "Asia/Magadan": "Russia", 270 | "Asia/Sakhalin": "Russia", 271 | "Asia/Srednekolymsk": "Russia", 272 | "Asia/Kamchatka": "Russia", 273 | "Asia/Anadyr": "Russia", 274 | "Asia/Riyadh": "Saudi Arabia", 275 | "Pacific/Guadalcanal": "Solomon Islands", 276 | "Indian/Mahe": "Seychelles", 277 | "Africa/Khartoum": "Sudan", 278 | "Europe/Stockholm": "Sweden", 279 | "Asia/Singapore": "Singapore", 280 | "America/Paramaribo": "Suriname", 281 | "Africa/Juba": "South Sudan", 282 | "Africa/Sao_Tome": "Sao Tome & Principe", 283 | "America/El_Salvador": "El Salvador", 284 | "Asia/Damascus": "Syria", 285 | "America/Grand_Turk": "Turks & Caicos Is", 286 | "Africa/Ndjamena": "Chad", 287 | "Indian/Kerguelen": "French Southern & Antarctic Lands", 288 | "Asia/Bangkok": "Thailand", 289 | "Asia/Dushanbe": "Tajikistan", 290 | "Pacific/Fakaofo": "Tokelau", 291 | "Asia/Dili": "East Timor", 292 | "Asia/Ashgabat": "Turkmenistan", 293 | "Africa/Tunis": "Tunisia", 294 | "Pacific/Tongatapu": "Tonga", 295 | "Europe/Istanbul": "Turkey", 296 | "Pacific/Funafuti": "Tuvalu", 297 | "Asia/Taipei": "Taiwan", 298 | "Europe/Kiev": "Ukraine", 299 | "Europe/Uzhgorod": "Ukraine", 300 | "Europe/Zaporozhye": "Ukraine", 301 | "Pacific/Wake": "US minor outlying islands", 302 | "America/New_York": "United States", 303 | "America/Detroit": "United States", 304 | "America/Kentucky/Louisville": "United States", 305 | "America/Kentucky/Monticello": "United States", 306 | "America/Indiana/Indianapolis": "United States", 307 | "America/Indiana/Vincennes": "United States", 308 | "America/Indiana/Winamac": "United States", 309 | "America/Indiana/Marengo": "United States", 310 | "America/Indiana/Petersburg": "United States", 311 | "America/Indiana/Vevay": "United States", 312 | "America/Chicago": "United States", 313 | "America/Indiana/Tell_City": "United States", 314 | "America/Indiana/Knox": "United States", 315 | "America/Menominee": "United States", 316 | "America/North_Dakota/Center": "United States", 317 | "America/North_Dakota/New_Salem": "United States", 318 | "America/North_Dakota/Beulah": "United States", 319 | "America/Denver": "United States", 320 | "America/Boise": "United States", 321 | "America/Phoenix": "United States", 322 | "America/Los_Angeles": "United States", 323 | "America/Anchorage": "United States", 324 | "America/Juneau": "United States", 325 | "America/Sitka": "United States", 326 | "America/Metlakatla": "United States", 327 | "America/Yakutat": "United States", 328 | "America/Nome": "United States", 329 | "America/Adak": "United States", 330 | "Pacific/Honolulu": "United States", 331 | "America/Montevideo": "Uruguay", 332 | "Asia/Samarkand": "Uzbekistan", 333 | "Asia/Tashkent": "Uzbekistan", 334 | "America/Caracas": "Venezuela", 335 | "Asia/Ho_Chi_Minh": "Vietnam", 336 | "Pacific/Efate": "Vanuatu", 337 | "Pacific/Wallis": "Wallis & Futuna", 338 | "Pacific/Apia": "Samoa (western)", 339 | "Africa/Johannesburg": "South Africa", 340 | "America/Antigua": "Antigua & Barbuda", 341 | "America/Anguilla": "Anguilla", 342 | "Africa/Luanda": "Angola", 343 | "Antarctica/McMurdo": "Antarctica", 344 | "Antarctica/DumontDUrville": "Antarctica", 345 | "Antarctica/Syowa": "Antarctica", 346 | "America/Aruba": "Aruba", 347 | "Europe/Mariehamn": "Åland Islands", 348 | "Europe/Sarajevo": "Bosnia & Herzegovina", 349 | "Africa/Ouagadougou": "Burkina Faso", 350 | "Asia/Bahrain": "Bahrain", 351 | "Africa/Bujumbura": "Burundi", 352 | "Africa/Porto-Novo": "Benin", 353 | "America/St_Barthelemy": "St Barthelemy", 354 | "America/Kralendijk": "Caribbean NL", 355 | "America/Nassau": "Bahamas", 356 | "Africa/Gaborone": "Botswana", 357 | "America/Blanc-Sablon": "Canada", 358 | "America/Atikokan": "Canada", 359 | "America/Creston": "Canada", 360 | "Africa/Kinshasa": "Congo (Dem. Rep.)", 361 | "Africa/Lubumbashi": "Congo (Dem. Rep.)", 362 | "Africa/Bangui": "Central African Rep.", 363 | "Africa/Brazzaville": "Congo (Rep.)", 364 | "Africa/Douala": "Cameroon", 365 | "America/Curacao": "Curaçao", 366 | "Europe/Busingen": "Germany", 367 | "Africa/Djibouti": "Djibouti", 368 | "America/Dominica": "Dominica", 369 | "Africa/Asmara": "Eritrea", 370 | "Africa/Addis_Ababa": "Ethiopia", 371 | "Africa/Libreville": "Gabon", 372 | "America/Grenada": "Grenada", 373 | "Europe/Guernsey": "Guernsey", 374 | "Africa/Accra": "Ghana", 375 | "Africa/Banjul": "Gambia", 376 | "Africa/Conakry": "Guinea", 377 | "America/Guadeloupe": "Guadeloupe", 378 | "Africa/Malabo": "Equatorial Guinea", 379 | "Europe/Zagreb": "Croatia", 380 | "Europe/Isle_of_Man": "Isle of Man", 381 | "Europe/Jersey": "Jersey", 382 | "Asia/Phnom_Penh": "Cambodia", 383 | "Indian/Comoro": "Comoros", 384 | "America/St_Kitts": "St Kitts & Nevis", 385 | "Asia/Kuwait": "Kuwait", 386 | "America/Cayman": "Cayman Islands", 387 | "Asia/Vientiane": "Laos", 388 | "America/St_Lucia": "St Lucia", 389 | "Europe/Vaduz": "Liechtenstein", 390 | "Africa/Maseru": "Lesotho", 391 | "Europe/Podgorica": "Montenegro", 392 | "America/Marigot": "St Martin (French)", 393 | "Indian/Antananarivo": "Madagascar", 394 | "Europe/Skopje": "North Macedonia", 395 | "Africa/Bamako": "Mali", 396 | "Pacific/Saipan": "Northern Mariana Islands", 397 | "Africa/Nouakchott": "Mauritania", 398 | "America/Montserrat": "Montserrat", 399 | "Africa/Blantyre": "Malawi", 400 | "Africa/Niamey": "Niger", 401 | "Asia/Muscat": "Oman", 402 | "Africa/Kigali": "Rwanda", 403 | "Atlantic/St_Helena": "St Helena", 404 | "Europe/Ljubljana": "Slovenia", 405 | "Arctic/Longyearbyen": "Svalbard & Jan Mayen", 406 | "Europe/Bratislava": "Slovakia", 407 | "Africa/Freetown": "Sierra Leone", 408 | "Europe/San_Marino": "San Marino", 409 | "Africa/Dakar": "Senegal", 410 | "Africa/Mogadishu": "Somalia", 411 | "America/Lower_Princes": "St Maarten (Dutch)", 412 | "Africa/Mbabane": "Eswatini (Swaziland)", 413 | "Africa/Lome": "Togo", 414 | "America/Port_of_Spain": "Trinidad & Tobago", 415 | "Africa/Dar_es_Salaam": "Tanzania", 416 | "Africa/Kampala": "Uganda", 417 | "Pacific/Midway": "US minor outlying islands", 418 | "Europe/Vatican": "Vatican City", 419 | "America/St_Vincent": "St Vincent", 420 | "America/Tortola": "Virgin Islands (UK)", 421 | "America/St_Thomas": "Virgin Islands (US)", 422 | "Asia/Aden": "Yemen", 423 | "Indian/Mayotte": "Mayotte", 424 | "Africa/Lusaka": "Zambia", 425 | "Africa/Harare": "Zimbabwe" 426 | } -------------------------------------------------------------------------------- /vue/src/App.vue: -------------------------------------------------------------------------------- 1 | 245 | 246 | 640 | 641 | 645 | -------------------------------------------------------------------------------- /docs/justwatch.graphql: -------------------------------------------------------------------------------- 1 | # Root Query Type 2 | type Query { 3 | streamingCharts( 4 | country: Country! 5 | filter: StreamingChartsFilter 6 | first: Int! = 70 7 | after: String 8 | ): StreamingChartsConnection 9 | # NOTE: JustWatch's streaming charts UI only exposes Global (null) plus 10 | # these tested countries: AR, AU, BR, CA, DE, EG, ES, FR, GB, IN, IT, MX, 11 | # PL, TR, US. Other country codes in the enum may not be accepted here. 12 | popularTitles( 13 | country: Country! 14 | filter: TitleFilter 15 | first: Int! = 70 16 | sortBy: PopularTitlesSorting! = POPULAR 17 | sortRandomSeed: Int! = 0 18 | offset: Int = 0 19 | after: String 20 | ): PopularTitlesConnection! 21 | packages(country: Country!, platform: Platform!): [Package!]! 22 | newTitleBuckets( 23 | country: Country! 24 | filter: TitleFilter 25 | after: String 26 | first: Int! = 2 27 | bucketSize: Int! = 8 28 | priceDrops: Boolean! 29 | pageType: NewPageType! 30 | groupBy: NewTitleAggregation! 31 | ): NewTitleBucketsConnection! 32 | newTitles( 33 | country: Country! 34 | date: Date! 35 | filter: TitleFilter 36 | after: String 37 | first: Int! = 10 38 | profile: PosterProfile 39 | format: ImageFormat 40 | backdropProfile: BackdropProfile 41 | priceDrops: Boolean! 42 | bucketType: NewDateRangeBucket 43 | pageType: NewPageType! 44 | showDateBadge: Boolean! 45 | availableToPackages: [String!] 46 | allowSponsoredRecommendations: SponsoredRecommendationsInput 47 | ): NewTitlesConnection! 48 | node(id: ID!): MovieOrShowOrSeasonOrEpisode 49 | } 50 | 51 | # Enums 52 | enum Platform { 53 | WEB 54 | } 55 | 56 | enum PopularTitlesSorting { 57 | POPULAR 58 | TRENDING 59 | } 60 | 61 | enum CreditRole { 62 | DIRECTOR 63 | } 64 | 65 | enum Country { 66 | NL 67 | AR 68 | US 69 | DE 70 | BR 71 | AU 72 | CA 73 | EG 74 | FR 75 | NZ 76 | IN 77 | IT 78 | MX 79 | PL 80 | ES 81 | TR 82 | GB 83 | ZA 84 | IE 85 | NE 86 | ME 87 | MG 88 | KW 89 | MZ 90 | KE 91 | UG 92 | TT 93 | TC 94 | ZM 95 | SN 96 | JM 97 | MD 98 | LI 99 | MC 100 | SM 101 | GI 102 | TN 103 | LY 104 | OM 105 | BM 106 | YE 107 | BS 108 | GF 109 | BA 110 | VA 111 | XK 112 | QA 113 | BY 114 | BZ 115 | CY 116 | CM 117 | GY 118 | ML 119 | NI 120 | CD 121 | MW 122 | TZ 123 | PG 124 | # more 125 | } 126 | 127 | enum Language { 128 | en 129 | de 130 | es 131 | fr 132 | hr 133 | it 134 | ja 135 | nl 136 | ru 137 | cy 138 | pt 139 | ar 140 | bs 141 | el 142 | sw 143 | sq 144 | be 145 | ro 146 | # more 147 | } 148 | 149 | enum ObjectType { 150 | SHOW 151 | MOVIE 152 | SEASON 153 | GENERIC_TITLE_LIST 154 | SHOW_SEASON 155 | EPISODE 156 | SHOW_EPISODE 157 | } 158 | 159 | enum MonetizationType { 160 | FREE 161 | FLATRATE 162 | ADS 163 | BUY 164 | RENT 165 | FLATRATE_AND_BUY 166 | CINEMA 167 | FAST 168 | } 169 | 170 | enum PresentationType { 171 | SD 172 | HD 173 | # 4K 174 | # Add other presentation types if observed 175 | } 176 | 177 | enum ClipProvider { 178 | DAILYMOTION 179 | } 180 | 181 | enum Trend { 182 | UP 183 | DOWN 184 | # Add other possible trends if observed, e.g., "SAME" 185 | } 186 | 187 | enum ImageFormat { 188 | JPG 189 | PNG 190 | WEBP 191 | # Add other specific formats if known, e.g., "GIF", "AVIF" 192 | } 193 | 194 | enum PosterProfile { 195 | S160 196 | S100 197 | # Add other specific profiles if known, e.g., "s300", "s500", "original" 198 | } 199 | 200 | enum BackdropProfile { 201 | S160 202 | S1440 203 | # Add other specific profiles if known, e.g., "s780", "s1280", "original" 204 | } 205 | 206 | enum NewTitleAggregation { 207 | DATE_PACKAGE 208 | # Add other aggregation types if observed 209 | } 210 | 211 | enum NewPageType { 212 | NEW 213 | # Add other page types if observed 214 | } 215 | 216 | enum NewDateRangeBucket { 217 | TODAY 218 | # Add other discovered values here 219 | } 220 | 221 | enum PopularityRankTypes { # New enum for category field 222 | WEEKLY_POPULARITY_SAME_CONTENT_TYPE 223 | DAILY_POPULARITY_SAME_CONTENT_TYPE 224 | MONTHLY_POPULARITY_SAME_CONTENT_TYPE 225 | # Add other category types as discovered 226 | } 227 | 228 | # Commented out missing enum types 229 | # enum SponsoredRecommendationAdCreativeType { 230 | # # Values to be inferred from usage, e.g., "IMAGE", "VIDEO" 231 | # } 232 | 233 | # enum SponsoredRecommendationExternalTrackerType { 234 | # # Values to be inferred from usage, e.g., "IMPRESSION", "CLICK" 235 | # } 236 | 237 | # enum SponsoredRecommendationImageSize { 238 | # # Values to be inferred from usage, e.g., "SMALL", "MEDIUM", "LARGE" 239 | # } 240 | 241 | # enum GenericTitleListType { 242 | # # Values to be inferred from usage, e.g., "CUSTOM_LIST", "WATCHLIST" 243 | # } 244 | 245 | # enum GenericTitleListVisibility { 246 | # # Values to be inferred from usage, e.g., "PUBLIC", "PRIVATE" 247 | # } 248 | 249 | # Input Types 250 | input TitleFilter { 251 | packages: [String!] 252 | objectTypes: [ObjectType!] 253 | ageCertifications: [String!] 254 | excludeGenres: [String!] 255 | excludeProductionCountries: [String!] 256 | productionCountries: [String!] 257 | subgenres: [String!] 258 | genres: [String!] 259 | excludeIrrelevantTitles: Boolean 260 | presentationTypes: [PresentationType!] 261 | monetizationTypes: [MonetizationType!] 262 | searchQuery: String 263 | clipType: String 264 | clipTechnicalProvider: String 265 | } 266 | 267 | input WatchNowOfferFilter { 268 | packages: [String!] 269 | monetizationTypes: [MonetizationType!] 270 | } 271 | 272 | input OfferCountFilter { 273 | monetizationTypes: [MonetizationType!] 274 | } 275 | 276 | input OfferFilter { 277 | packages: [String!] 278 | bestOnly: Boolean 279 | preAffiliate: Boolean 280 | } 281 | 282 | input StreamingChartsFilter { 283 | category: PopularityRankTypes 284 | objectType: ObjectType 285 | nextTitles: Int 286 | previousTitles: Int 287 | packages: [String!] # Added based on new query 288 | } 289 | 290 | input SponsoredRecommendationsInput { 291 | pageType: NewPageType! 292 | placement: String! 293 | language: Language! 294 | country: Country! 295 | applicationContext: ApplicationContextInput! 296 | appId: String! 297 | platform: Platform! 298 | # Commented out missing enum types 299 | # supportedFormats: [SponsoredRecommendationSupportedFormat!] 300 | # supportedObjectTypes: [SponsoredRecommendationSupportedObjectType!] 301 | alwaysReturnBidID: Boolean! 302 | testingModeForceHoldoutGroup: Boolean! 303 | testingMode: Boolean! 304 | } 305 | 306 | input ApplicationContextInput { 307 | appID: String! 308 | platform: String! 309 | version: String! 310 | build: String! 311 | isTestBuild: Boolean! 312 | } 313 | 314 | scalar Date # New scalar type for date argument 315 | 316 | 317 | # Object Types 318 | type PopularTitlesConnection { 319 | edges: [PopularTitlesEdge!]! 320 | pageInfo: PageInfo! 321 | totalCount: Int! 322 | } 323 | 324 | type PopularTitlesEdge { 325 | cursor: String! 326 | node: Title! 327 | } 328 | 329 | type PageInfo { 330 | startCursor: String 331 | endCursor: String 332 | hasPreviousPage: Boolean! 333 | hasNextPage: Boolean! 334 | } 335 | 336 | union Title = Movie | Show | Season 337 | 338 | # New union specifically for MovieOrShow 339 | union MovieOrShow = Movie | Show 340 | 341 | interface TitleInterface { 342 | id: ID! 343 | objectId: Int! 344 | objectType: ObjectType! 345 | streamingCharts(country: Country!, filter: StreamingChartsFilter): StreamingChartsConnectionMovie 346 | content(country: Country!, language: Language!): Content # Returns Content union 347 | likelistEntry: ListEntry 348 | dislikelistEntry: ListEntry 349 | watchlistEntryV2: ListEntry 350 | customlistEntries: [ListEntry!] 351 | watchNowOffer(country: Country!, platform: Platform!, filter: WatchNowOfferFilter!): Offer 352 | offers(country: Country!, platform: Platform!, filter: OfferFilter!): [Offer!] 353 | freeOffersCount: Int 354 | offerCount(country: Country!, platform: Platform!, filter: OfferCountFilter!): Int 355 | maxOfferUpdatedAt(country: Country!, platform: Platform!): String 356 | bundles(country: Country!, platform: Platform!): [Bundle!] 357 | } 358 | 359 | type Movie implements TitleInterface { 360 | id: ID! 361 | objectId: Int! 362 | objectType: ObjectType! 363 | streamingCharts(country: Country!, filter: StreamingChartsFilter): StreamingChartsConnectionMovie 364 | content(country: Country!, language: Language!): MovieContent # Returns MovieContent 365 | likelistEntry: ListEntry 366 | dislikelistEntry: ListEntry 367 | watchlistEntryV2: ListEntry 368 | customlistEntries: [ListEntry!] 369 | watchNowOffer(country: Country!, platform: Platform!, filter: WatchNowOfferFilter!): Offer 370 | offers(country: Country!, platform: Platform!, filter: OfferFilter!): [Offer!] 371 | freeOffersCount: Int 372 | offerCount(country: Country!, platform: Platform!, filter: OfferCountFilter!): Int 373 | seenlistEntry: ListEntry 374 | maxOfferUpdatedAt(country: Country!, platform: Platform!): String 375 | bundles(country: Country!, platform: Platform!): [Bundle!] 376 | } 377 | 378 | type Show implements TitleInterface { 379 | id: ID! 380 | objectId: Int! 381 | objectType: ObjectType! 382 | streamingCharts(country: Country!, filter: StreamingChartsFilter): StreamingChartsConnectionMovie 383 | content(country: Country!, language: Language!): ShowContent # Returns ShowContent 384 | likelistEntry: ListEntry 385 | dislikelistEntry: ListEntry 386 | watchlistEntryV2: ListEntry 387 | customlistEntries: [ListEntry!] 388 | watchNowOffer(country: Country!, platform: Platform!, filter: WatchNowOfferFilter!): Offer 389 | offers(country: Country!, platform: Platform!, filter: OfferFilter!): [Offer!] 390 | freeOffersCount: Int 391 | offerCount(country: Country!, platform: Platform!, filter: OfferCountFilter!): Int 392 | tvShowTrackingEntry: ListEntry 393 | seenState(country: Country!): SeenState 394 | maxOfferUpdatedAt(country: Country!, platform: Platform!): String 395 | bundles(country: Country!, platform: Platform!): [Bundle!] 396 | } 397 | 398 | type Season implements TitleInterface { 399 | id: ID! 400 | objectId: Int! 401 | objectType: ObjectType! 402 | streamingCharts(country: Country!, filter: StreamingChartsFilter): StreamingChartsConnectionMovie 403 | content(country: Country!, language: Language!): SeasonContent # Returns SeasonContent 404 | likelistEntry: ListEntry 405 | dislikelistEntry: ListEntry 406 | watchlistEntryV2: ListEntry 407 | customlistEntries: [ListEntry!] 408 | watchNowOffer(country: Country!, platform: Platform!, filter: WatchNowOfferFilter!): Offer 409 | offers(country: Country!, platform: Platform!, filter: OfferFilter!): [Offer!] 410 | freeOffersCount: Int 411 | offerCount(country: Country!, platform: Platform!, filter: OfferCountFilter!): Int 412 | maxOfferUpdatedAt(country: Country!, platform: Platform!): String 413 | bundles(country: Country!, platform: Platform!): [Bundle!] 414 | episodes(limit: Int, offset: Int): [Episode!] 415 | } 416 | 417 | type Episode implements TitleInterface { 418 | id: ID! 419 | objectId: Int! 420 | objectType: ObjectType! 421 | streamingCharts(country: Country!, filter: StreamingChartsFilter): StreamingChartsConnectionMovie 422 | content(country: Country!, language: Language!): EpisodeContent 423 | likelistEntry: ListEntry 424 | dislikelistEntry: ListEntry 425 | watchlistEntryV2: ListEntry 426 | customlistEntries: [ListEntry!] 427 | watchNowOffer(country: Country!, platform: Platform!, filter: WatchNowOfferFilter!): Offer 428 | offers(country: Country!, platform: Platform!, filter: OfferFilter!): [Offer!] 429 | freeOffersCount: Int 430 | offerCount(country: Country!, platform: Platform!, filter: OfferCountFilter!): Int 431 | maxOfferUpdatedAt(country: Country!, platform: Platform!): String 432 | bundles(country: Country!, platform: Platform!): [Bundle!] 433 | seenlistEntry: ListEntry 434 | uniqueOfferCount(country: Country!, platform: Platform!, filter: OfferFilter): Int 435 | flatrate: [Offer!] 436 | buy: [Offer!] 437 | rent: [Offer!] 438 | free: [Offer!] 439 | fast: [Offer!] 440 | } 441 | 442 | 443 | # Represents a connection of streaming charts (top-level) 444 | type StreamingChartsConnection { 445 | totalCount: Int! 446 | pageInfo: PageInfo! 447 | edges: [StreamingChartsTitlesEdge!]! 448 | } 449 | 450 | # An edge in the top-level streaming charts connection 451 | type StreamingChartsTitlesEdge { 452 | streamingChartInfo: StreamingChartInfo! 453 | node: Movie! # Assuming 'node' will always be a Movie in this context for this connection 454 | } 455 | 456 | type StreamingChartsConnectionMovie { 457 | edges: [StreamingChartsTitlesEdgeMovie!]! 458 | } 459 | 460 | # An edge in the nested streaming charts connection for a Movie/Show/Season 461 | type StreamingChartsTitlesEdgeMovie { 462 | streamingChartInfo: StreamingChartInfo! 463 | } 464 | 465 | # Information about a specific streaming chart entry 466 | type StreamingChartInfo { 467 | rank: Int! 468 | trend: Trend! 469 | trendDifference: Int! 470 | daysInTop10: Int 471 | topRank: Int 472 | } 473 | 474 | # Content union now explicitly lists different content types 475 | union Content = MovieContent | ShowContent | SeasonContent | EpisodeContent 476 | 477 | interface ContentInterface { 478 | title: String! 479 | fullPath: String! 480 | scoring: Scoring 481 | posterUrl(profile: PosterProfile, format: ImageFormat): String 482 | isReleased: Boolean 483 | credits(role: CreditRole!): [Credit!] 484 | runtime: Int 485 | originalReleaseYear: Int 486 | genres: [Genre!] 487 | interactions: Interactions 488 | clips(providers: [ClipProvider!]): [Clip!] 489 | dailymotionClips: [Clip!] 490 | externalIds: ExternalIds 491 | } 492 | 493 | type MovieContent implements ContentInterface { 494 | title: String! 495 | fullPath: String! 496 | scoring: Scoring 497 | posterUrl(profile: PosterProfile, format: ImageFormat): String 498 | backdrops(profile: BackdropProfile, format: ImageFormat): [Backdrop!] 499 | isReleased: Boolean 500 | credits(role: CreditRole!): [Credit!] 501 | runtime: Int 502 | originalReleaseYear: Int 503 | genres: [Genre!] 504 | interactions: Interactions 505 | clips(providers: [ClipProvider!]): [Clip!] 506 | dailymotionClips: [Clip!] 507 | externalIds: ExternalIds 508 | shortDescription: String 509 | } 510 | 511 | type ShowContent implements ContentInterface { 512 | title: String! 513 | fullPath: String! 514 | scoring: Scoring 515 | posterUrl(profile: PosterProfile, format: ImageFormat): String 516 | backdrops(profile: BackdropProfile, format: ImageFormat): [Backdrop!] 517 | isReleased: Boolean 518 | credits(role: CreditRole!): [Credit!] 519 | runtime: Int 520 | originalReleaseYear: Int 521 | genres: [Genre!] 522 | interactions: Interactions 523 | clips(providers: [ClipProvider!]): [Clip!] 524 | dailymotionClips: [Clip!] 525 | externalIds: ExternalIds 526 | shortDescription: String 527 | } 528 | 529 | type SeasonContent implements ContentInterface { 530 | title: String! 531 | fullPath: String! 532 | scoring: Scoring 533 | posterUrl(profile: PosterProfile, format: ImageFormat): String 534 | backdrops(profile: BackdropProfile, format: ImageFormat): [Backdrop!] 535 | isReleased: Boolean 536 | credits(role: CreditRole!): [Credit!] 537 | runtime: Int 538 | originalReleaseYear: Int 539 | genres: [Genre!] 540 | interactions: Interactions 541 | clips(providers: [ClipProvider!]): [Clip!] 542 | dailymotionClips: [Clip!] 543 | externalIds: ExternalIds 544 | seasonNumber: Int 545 | shortDescription: String 546 | } 547 | 548 | type EpisodeContent implements ContentInterface { 549 | title: String! 550 | fullPath: String! 551 | scoring: Scoring 552 | posterUrl(profile: PosterProfile, format: ImageFormat): String 553 | # backdrops: [Backdrop!] # Not seen in EpisodeContent response 554 | isReleased: Boolean 555 | credits(role: CreditRole!): [Credit!] 556 | runtime: Int 557 | originalReleaseYear: Int 558 | genres: [Genre!] 559 | interactions: Interactions 560 | clips(providers: [ClipProvider!]): [Clip!] 561 | dailymotionClips: [Clip!] 562 | externalIds: ExternalIds 563 | shortDescription: String 564 | episodeNumber: Int 565 | seasonNumber: Int 566 | upcomingReleases: [UpcomingRelease!] 567 | } 568 | 569 | type Scoring { 570 | imdbVotes: Int 571 | imdbScore: Float 572 | tmdbPopularity: Float 573 | tmdbScore: Float 574 | tomatoMeter: Int 575 | certifiedFresh: Boolean 576 | jwRating: Float 577 | } 578 | 579 | type Interactions { 580 | likelistAdditions: Int 581 | dislikelistAdditions: Int 582 | votesNumber: Int 583 | } 584 | 585 | type Clip { 586 | sourceUrl: String! 587 | externalId: String! 588 | provider: ClipProvider! 589 | streamUrl: String! 590 | } 591 | 592 | type ExternalIds { 593 | imdbId: String 594 | # Likely other external IDs exist like: 595 | # tmdbId: String 596 | # tvdbId: String 597 | # etc. 598 | } 599 | 600 | type Backdrop { 601 | backdropUrl: String! 602 | } 603 | 604 | type Credit { 605 | name: String! 606 | personId: String! 607 | } 608 | 609 | type Genre { 610 | translation(language: Language!): String 611 | shortName: String! 612 | } 613 | 614 | type ListEntry { 615 | createdAt: String! 616 | } 617 | 618 | type SeenState { 619 | seenEpisodeCount: Int 620 | progress: Float 621 | 622 | } 623 | 624 | type Offer { 625 | id: ID! 626 | presentationType: PresentationType! 627 | monetizationType: MonetizationType! 628 | newElementCount: Int 629 | retailPrice(language: Language!): String 630 | retailPriceValue: Float 631 | currency: String 632 | lastChangeRetailPriceValue: Float 633 | type: String 634 | country: Country 635 | package: Package! 636 | standardWebURL: String 637 | preAffiliatedStandardWebURL: String 638 | streamUrl: String 639 | elementCount: Int 640 | availableTo: String 641 | subtitleLanguages: [String!] 642 | videoTechnology: [String!] 643 | audioTechnology: [String!] 644 | audioLanguages(language: Language!): [String!] 645 | lastChangeRetailPrice(language: Language!): String 646 | lastChangePercent: Float 647 | } 648 | 649 | type Package { 650 | id: ID! 651 | icon(profile: PosterProfile): String 652 | packageId: Int! 653 | clearName: String! 654 | shortName: String! 655 | technicalName: String! 656 | iconWide(profile: PosterProfile): String 657 | hasRectangularIcon(country: Country!, platform: Platform!): Boolean 658 | planOffers(country: Country!, platform: Platform!): [PackagePlanOffer!] 659 | } 660 | 661 | # New types for New Title Buckets 662 | type NewTitleBucketsConnection { 663 | pageInfo: PageInfo! 664 | edges: [NewTitleBucketsEdge!]! 665 | } 666 | 667 | type NewTitleBucketsEdge { 668 | key: NewBucketAggregationKey! 669 | node: NewTitleBucketNode! 670 | } 671 | 672 | union NewBucketAggregationKey = DatePackageAggregationKey | BucketPackageAggregationKey 673 | 674 | type DatePackageAggregationKey { 675 | date: String! # Assuming date is a String (e.g., "2025-05-22") 676 | package: Package! 677 | } 678 | 679 | type BucketPackageAggregationKey { 680 | bucketType: String! # Assuming bucketType is a String 681 | package: Package! 682 | } 683 | 684 | type NewTitleBucketNode { 685 | totalCount: Int! 686 | pageInfo: PageInfo! 687 | } 688 | 689 | # New types for New Titles 690 | type NewTitlesConnection { 691 | totalCount: Int! 692 | edges: [NewTitlesEdge!]! 693 | sponsoredAd: SponsoredAd 694 | pageInfo: PageInfo! 695 | } 696 | 697 | type NewTitlesEdge { 698 | cursor: String 699 | newOffer(platform: Platform!): Offer 700 | node: NewTitleNode! 701 | } 702 | 703 | union NewTitleNode = Movie | Season | GenericTitleList 704 | 705 | union MovieOrSeason = Movie | Season 706 | 707 | union MovieOrShowOrSeason = Movie | Show | Season 708 | 709 | union MovieOrShowOrSeasonOrEpisode = Movie | Show | Season | Episode 710 | 711 | union MovieOrShowOrSeasonContent = MovieContent | ShowContent | SeasonContent # New union type 712 | 713 | 714 | type UpcomingRelease { 715 | releaseDate: String! 716 | package: Package! 717 | releaseCountDown(country: Country!): String 718 | label: String 719 | } 720 | 721 | type AvailableToInfo { 722 | availableCountDown(country: Country!): String 723 | package: Package! 724 | availableToDate: String! 725 | } 726 | 727 | type GenericTitleList { 728 | followedlistEntry: ListEntry 729 | id: ID! 730 | # Removed 'type' field as GenericTitleListType is commented out 731 | content(country: Country!, language: Language!): GenericTitleListContent! 732 | titles(country: Country!, first: Int!): GenericTitleListTitlesConnection! 733 | } 734 | 735 | type GenericTitleListContent { 736 | name: String! 737 | # Removed 'visibility' field as GenericTitleListVisibility is commented out 738 | } 739 | 740 | type GenericTitleListTitlesConnection { 741 | totalCount: Int! 742 | edges: [GenericTitleListTitlesEdge!]! 743 | } 744 | 745 | type GenericTitleListTitlesEdge { 746 | cursor: String 747 | node: GenericTitleListNode! # nodeV2 alias 748 | } 749 | 750 | union GenericTitleListNode = Movie | Show | Season 751 | 752 | 753 | type SponsoredAd { 754 | bidId: String 755 | holdoutGroup: Boolean 756 | campaign: SponsoredCampaign! 757 | nodeOverrides: [SponsoredNodeOverride!] 758 | node: MovieOrShowOrSeason 759 | } 760 | 761 | type SponsoredCampaign { 762 | name: String 763 | backgroundImages: [SponsoredBackgroundImage!] 764 | countdownTimer: Int 765 | # creativeType: SponsoredRecommendationAdCreativeType # Commented out 766 | disclaimerText: String 767 | externalTrackers: [SponsoredExternalTracker!] 768 | hideDetailPageButton: Boolean 769 | hideImdbScore: Boolean 770 | hideJwScore: Boolean 771 | hideRatings: Boolean 772 | hideContent: Boolean 773 | posterOverride: String 774 | playSRVideoAsGif: Boolean 775 | promotionalImageUrl: String 776 | promotionalVideo: PromotionalVideo 777 | promotionalTitle: String 778 | promotionalText: String 779 | promotionalProviderLogo: String 780 | promotionalProviderWideLogo: String 781 | watchNowLabel: String 782 | watchNowOffer: Offer 783 | nodeOverrides: [SponsoredNodeOverride!] 784 | node: MovieOrShowOrSeason 785 | } 786 | 787 | type SponsoredBackgroundImage { 788 | imageURL: String 789 | # size: SponsoredRecommendationImageSize # Commented out 790 | } 791 | 792 | type SponsoredExternalTracker { 793 | # type: SponsoredRecommendationExternalTrackerType # Commented out 794 | data: String 795 | } 796 | 797 | # Commented out the enum definitions themselves 798 | # enum SponsoredRecommendationSupportedFormat { 799 | # IMAGE 800 | # # VIDEO 801 | # } 802 | 803 | # enum SponsoredRecommendationSupportedObjectType { 804 | # MOVIE 805 | # SHOW 806 | # GENERIC_TITLE_LIST 807 | # SHOW_SEASON 808 | # } 809 | 810 | # enum SponsoredRecommendationAdCreativeType { 811 | # # Values to be inferred from usage, e.g., "IMAGE", "VIDEO" 812 | # } 813 | 814 | # enum SponsoredRecommendationExternalTrackerType { 815 | # # Values to be inferred from usage, e.g., "IMPRESSION", "CLICK" 816 | # } 817 | 818 | # enum SponsoredRecommendationImageSize { 819 | # # Values to be inferred from usage, e.g., "SMALL", "MEDIUM", "LARGE" 820 | # } 821 | 822 | # enum GenericTitleListType { 823 | # # Values to be inferred from usage, e.g., "CUSTOM_LIST", "WATCHLIST" 824 | # } 825 | 826 | # enum GenericTitleListVisibility { 827 | # # Values to be inferred from usage, e.g., "PUBLIC", "PRIVATE" 828 | # } 829 | 830 | 831 | type PromotionalVideo { 832 | url: String 833 | } 834 | 835 | type SponsoredNodeOverride { 836 | nodeId: ID! 837 | promotionalImageUrl: String 838 | watchNowOffer: SponsoredNodeOverrideOffer 839 | } 840 | 841 | type SponsoredNodeOverrideOffer { 842 | standardWebURL: String 843 | } 844 | 845 | type Bundle { 846 | id: ID! 847 | clearName: String! 848 | icon(profile: PosterProfile): String 849 | technicalName: String! 850 | bundleId: String! 851 | packages(country: Country!, platform: Platform!): [Package!]! 852 | promotionUrl: String 853 | offer: Offer 854 | } 855 | 856 | type PackagePlanOffer { 857 | title: String! 858 | retailPrice(language: Language!): String 859 | isTrial: Boolean! 860 | durationDays: Int 861 | retailPriceValue: Float 862 | children: [PackagePlanOffer!] 863 | } 864 | 865 | query Packages { 866 | packages(platform: WEB, country: US) { 867 | id 868 | icon 869 | packageId 870 | clearName 871 | shortName 872 | technicalName 873 | iconWide 874 | } 875 | } 876 | 877 | # Example streaming charts query (movies or shows). Set $countryStreamingCharts 878 | # to null (Global) or one of the supported codes from the comment above. 879 | query GetStreamingChartInfo( 880 | $countryStreamingCharts: Country 881 | $country: Country! 882 | $language: Language! 883 | $filter: StreamingChartsFilter 884 | $first: Int! = 10 885 | $platform: Platform! = WEB 886 | $withOffers: Boolean! = true 887 | $after: String = "" 888 | ) { 889 | streamingCharts( 890 | country: $countryStreamingCharts 891 | filter: $filter 892 | first: $first 893 | after: $after 894 | ) { 895 | edges { 896 | streamingChartInfo { 897 | rank 898 | trend 899 | trendDifference 900 | updatedAt 901 | daysInTop10 902 | daysInTop100 903 | daysInTop1000 904 | daysInTop3 905 | topRank 906 | } 907 | cursor 908 | node { 909 | id 910 | objectId 911 | objectType 912 | offerCount(country: $country, platform: $platform) @include(if: $withOffers) 913 | offers(country: $country, platform: $platform, filter: { preAffiliate: true }) @include(if: $withOffers) { 914 | ...TitleOffer 915 | } 916 | watchNowOffer(country: $country, platform: $platform) { 917 | id 918 | standardWebURL 919 | preAffiliatedStandardWebURL 920 | package { 921 | id 922 | packageId 923 | clearName 924 | } 925 | retailPrice(language: $language) 926 | retailPriceValue 927 | lastChangeRetailPriceValue 928 | currency 929 | presentationType 930 | monetizationType 931 | } 932 | ... on MovieOrShowOrSeason { 933 | content(country: $country, language: $language) { 934 | title 935 | fullPath 936 | posterUrl 937 | fullPosterUrl: posterUrl(profile: S166, format: JPG) 938 | runtime 939 | originalReleaseYear 940 | genres { 941 | shortName 942 | } 943 | } 944 | } 945 | } 946 | } 947 | totalCount 948 | pageInfo { 949 | startCursor 950 | hasPreviousPage 951 | hasNextPage 952 | endCursor 953 | } 954 | } 955 | } 956 | 957 | fragment TitleOffer on Offer { 958 | id 959 | presentationType 960 | monetizationType 961 | newElementCount 962 | retailPrice(language: $language) 963 | retailPriceValue 964 | userLocalCurrency 965 | retailPriceConverted(language: $language) 966 | currency 967 | lastChangeRetailPriceValue 968 | type 969 | country 970 | package { 971 | id 972 | packageId 973 | clearName 974 | shortName 975 | technicalName 976 | icon(profile: S100) 977 | iconWide(profile: S160) 978 | planOffers(country: $country, platform: WEB) { 979 | title 980 | retailPrice(language: $language) 981 | isTrial 982 | durationDays 983 | retailPriceValue 984 | retailPriceConverted(language: $language) 985 | children { 986 | title 987 | retailPrice(language: $language) 988 | isTrial 989 | durationDays 990 | retailPriceValue 991 | } 992 | } 993 | } 994 | plans(platform: WEB) { 995 | title 996 | retailPrice(language: $language) 997 | isTrial 998 | durationDays 999 | retailPriceValue 1000 | retailPriceConverted(language: $language) 1001 | children { 1002 | title 1003 | retailPrice(language: $language) 1004 | isTrial 1005 | durationDays 1006 | retailPriceValue 1007 | } 1008 | } 1009 | standardWebURL 1010 | preAffiliatedStandardWebURL 1011 | streamUrl 1012 | streamUrlExternalPlayer 1013 | elementCount 1014 | availableTo 1015 | subtitleLanguages 1016 | videoTechnology 1017 | audioTechnology 1018 | audioLanguages(language: $language) 1019 | } 1020 | --------------------------------------------------------------------------------