├── .dockerignore ├── static ├── bg.jpg ├── logo.png └── screenshot_discover.jpg ├── .gitignore ├── Dockerfile ├── src ├── HttpClient.js ├── adapters │ ├── PornHub.js │ ├── RedTube.js │ ├── YouPorn.js │ ├── SpankWire.js │ ├── PornCom.js │ ├── Chaturbate.js │ ├── BaseAdapter.js │ ├── HubTrafficAdapter.js │ └── EPorner.js ├── index.js └── PornClient.js ├── tests ├── adapters │ ├── PornHub │ │ └── PornHub.test.js │ ├── RedTube │ │ └── RedTube.test.js │ ├── YouPorn │ │ ├── YouPorn.test.js │ │ └── embeddedMoviePage.html │ ├── SpankWire │ │ ├── SpankWire.test.js │ │ └── embeddedMoviePage.html │ ├── Chaturbate │ │ └── Chaturbate.test.js │ ├── testAdapter.js │ ├── PornCom │ │ ├── PornCom.test.js │ │ └── embedPage.xml │ └── EPorner │ │ ├── EPorner.test.js │ │ └── apiResponse.xml └── index.test.js ├── dist ├── adapters │ ├── PornHub.js │ ├── RedTube.js │ ├── PornHub.js.map │ ├── YouPorn.js │ ├── RedTube.js.map │ ├── SpankWire.js │ ├── YouPorn.js.map │ ├── SpankWire.js.map │ ├── PornCom.js │ ├── Chaturbate.js │ ├── HubTrafficAdapter.js │ ├── BaseAdapter.js │ ├── PornCom.js.map │ ├── EPorner.js │ ├── Chaturbate.js.map │ ├── BaseAdapter.js.map │ ├── HubTrafficAdapter.js.map │ └── EPorner.js.map ├── HttpClient.js.map ├── HttpClient.js ├── index.js ├── index.js.map └── PornClient.js ├── package.json ├── README.md └── .eslintrc.json /.dockerignore: -------------------------------------------------------------------------------- 1 | ./** 2 | !dist/** 3 | !static/** 4 | !package.json 5 | -------------------------------------------------------------------------------- /static/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naughty-doge/stremio-porn/HEAD/static/bg.jpg -------------------------------------------------------------------------------- /static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naughty-doge/stremio-porn/HEAD/static/logo.png -------------------------------------------------------------------------------- /static/screenshot_discover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naughty-doge/stremio-porn/HEAD/static/screenshot_discover.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | wip 2 | node_modules 3 | .DS_Store 4 | Thumbs.db 5 | .idea 6 | .vs 7 | .vscode 8 | *.log 9 | .npmrc 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | WORKDIR /var/stremio_addon 4 | # The exact files included are controlled by .dockerignore 5 | COPY . . 6 | RUN npm install --only=prod --no-package-lock 7 | 8 | CMD node dist/index.js 9 | -------------------------------------------------------------------------------- /src/HttpClient.js: -------------------------------------------------------------------------------- 1 | import got from 'got' 2 | import HttpsProxyAgent from 'https-proxy-agent' 3 | import HttpProxyAgent from 'http-proxy-agent' 4 | 5 | 6 | const DEFAULT_HEADERS = { 7 | 'user-agent': 'stremio-porn', 8 | } 9 | const DEFAULT_REQUEST_OPTIONS = { 10 | timeout: 20000, 11 | } 12 | 13 | 14 | class HttpClient { 15 | baseRequestOptions = { 16 | ...DEFAULT_REQUEST_OPTIONS, 17 | } 18 | 19 | constructor(options = {}) { 20 | if (options.proxy) { 21 | let [host, port] = options.proxy.split(':') 22 | let agentOptions = { host, port, secureProxy: true } 23 | 24 | this.baseRequestOptions.agent = { 25 | http: new HttpProxyAgent(agentOptions), 26 | https: new HttpsProxyAgent(agentOptions), 27 | } 28 | } 29 | } 30 | 31 | request(url, reqOptions = {}) { 32 | let headers 33 | 34 | if (reqOptions.headers) { 35 | headers = { ...DEFAULT_HEADERS, ...reqOptions.headers } 36 | } else { 37 | headers = DEFAULT_HEADERS 38 | } 39 | 40 | reqOptions = { 41 | ...this.baseRequestOptions, 42 | ...reqOptions, 43 | headers, 44 | } 45 | 46 | return got(url, reqOptions) 47 | } 48 | } 49 | 50 | 51 | export default HttpClient 52 | -------------------------------------------------------------------------------- /tests/adapters/PornHub/PornHub.test.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import testAdapter from '../testAdapter' 3 | import PornHub from '../../../src/adapters/PornHub' 4 | 5 | 6 | const EMBED_PAGE = readFileSync(`${__dirname}/embeddedMoviePage.html`, 'utf8') 7 | 8 | const ITEMS = [{ 9 | id: 'ph598cafd0ca22e', 10 | type: 'movie', 11 | streams: ['201708/10/128079091'], 12 | match: { 13 | name: 'Amateur Girl Fucked With Cum On Face / Fucked After Facial', 14 | }, 15 | }, { 16 | id: 'ph5a94a343e79fe', 17 | type: 'movie', 18 | streams: ['201802/27/156159022'], 19 | match: { 20 | name: 'POV edging blowjob tongue part 2', 21 | }, 22 | }] 23 | 24 | 25 | describe('PornHub', () => { 26 | testAdapter(PornHub, ITEMS) 27 | 28 | describe('#_extractStreamsFromEmbed()', () => { 29 | test('retrieves a stream from a sample embedded movie page', () => { 30 | let adapter = new PornHub() 31 | let result = adapter._extractStreamsFromEmbed(EMBED_PAGE) 32 | 33 | expect(result).toEqual([{ 34 | url: 'https://de.phncdn.com/videos/201503/28/46795732/vl_480_493k_46795732.mp4?ttl=1522227092&ri=1228800&rs=696&hash=268b5f4d76927209ef554ac9e93c6c85', 35 | }]) 36 | }) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /tests/adapters/RedTube/RedTube.test.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import testAdapter from '../testAdapter' 3 | import RedTube from '../../../src/adapters/RedTube' 4 | 5 | 6 | const EMBED_PAGE = readFileSync(`${__dirname}/embeddedVideoPage.html`, 'utf8') 7 | 8 | const ITEMS = [{ 9 | id: 1, 10 | type: 'movie', 11 | streams: ['201208/31/1/480p_600k_1.mp4'], 12 | match: { 13 | name: 'Heather taking it deep again', 14 | }, 15 | }, { 16 | id: 4848071, 17 | type: 'movie', 18 | streams: ['201803/08/4848071/480P_600K_4848071.mp4'], 19 | match: { 20 | name: 'Brother Caught Redhead Step-Sister Masturbate and Fuck Anal', 21 | }, 22 | }] 23 | 24 | 25 | describe('RedTube', () => { 26 | testAdapter(RedTube, ITEMS) 27 | 28 | describe('#_extractStreamsFromEmbed()', () => { 29 | test('retrieves a stream from a sample embedded video page', () => { 30 | let adapter = new RedTube() 31 | let result = adapter._extractStreamsFromEmbed(EMBED_PAGE) 32 | 33 | expect(result).toEqual([{ 34 | quality: '480p', 35 | url: 'https://ce.rdtcdn.com/media/videos/201803/08/4848071/480P_600K_4848071.mp4?a5dcae8e1adc0bdaed975f0d66fb5e0568d9f5b553250a40db6040349e33a09c6fe9df21d2172658c3212e4a12a1aa10d7abea9c9e32593783053be05a3d5ee05e116588562463e0e6234de008e847b568c7c15d714814801dc24012fb8cf118017f49853398246c7335d1d54773a963ab867f31244ca5ba17067b7bae', 36 | }]) 37 | }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/adapters/PornHub.js: -------------------------------------------------------------------------------- 1 | import HubTrafficAdapter from './HubTrafficAdapter' 2 | 3 | 4 | class PornHub extends HubTrafficAdapter { 5 | static DISPLAY_NAME = 'PornHub' 6 | static ITEMS_PER_PAGE = 30 7 | static VIDEO_ID_PARAMETER = 'id' 8 | 9 | _makeMethodUrl(method) { 10 | let methodAliases = { 11 | searchVideos: 'search', 12 | getVideoById: 'video_by_id', 13 | } 14 | return `https://www.pornhub.com/webmasters/${methodAliases[method]}` 15 | } 16 | 17 | _makeEmbedUrl(id) { 18 | return `https://www.pornhub.com/embed/${id}` 19 | } 20 | 21 | _extractStreamsFromEmbed(body) { 22 | /* eslint-disable max-len */ 23 | // URL example: 24 | // https:\/\/de.phncdn.com\/videos\/201503\/28\/46795732\/vl_480_493k_46795732.mp4?ttl=1522227092&ri=1228800&rs=696&hash=268b5f4d76927209ef554ac9e93c6c85 25 | let regexp = /videoUrl["']?\s*:\s*["']?(https?:\\?\/\\?\/[a-z]+\.phncdn\.com[^"']+)/gi 26 | /* eslint-enable max-len */ 27 | let urlMatches = regexp.exec(body) 28 | 29 | if (!urlMatches || !urlMatches[1]) { 30 | throw new Error('Unable to extract a stream URL from an embed page') 31 | } 32 | 33 | let url = urlMatches[1] 34 | .replace(/[\\/]+/g, '/') // Normalize the slashes... 35 | .replace(/(https?:\/)/, '$1/') // ...but keep the // after "https:" 36 | 37 | if (url[0] === '/') { 38 | url = `https:/${url}` 39 | } 40 | 41 | return [{ url }] 42 | } 43 | } 44 | 45 | 46 | export default PornHub 47 | -------------------------------------------------------------------------------- /src/adapters/RedTube.js: -------------------------------------------------------------------------------- 1 | import HubTrafficAdapter from './HubTrafficAdapter' 2 | 3 | 4 | class RedTube extends HubTrafficAdapter { 5 | static DISPLAY_NAME = 'RedTube' 6 | static TAGS_TO_SKIP = ['teens'] // For some reason Teens doesn't work properly 7 | static ITEMS_PER_PAGE = 20 8 | 9 | _makeMethodUrl(method) { 10 | return `https://api.redtube.com?data=redtube.Videos.${method}` 11 | } 12 | 13 | _makeEmbedUrl(id) { 14 | return `https://embed.redtube.com?id=${id}` 15 | } 16 | 17 | _extractStreamsFromEmbed(body) { 18 | /* eslint-disable max-len */ 19 | // URL example: 20 | // https://ce.rdtcdn.com/media/videos/201803/12/4930561/480P_600K_4930561.mp4?a5dcae8e1adc0bdaed975f0... 21 | let regexp = /videoUrl["']?\s*:\s*["']?(https?:\\?\/\\?\/[a-z_-]+\.rdtcdn\.com[^"']+)/gi 22 | /* eslint-enable max-len */ 23 | let urlMatches = regexp.exec(body) 24 | 25 | if (!urlMatches || !urlMatches[1]) { 26 | throw new Error('Unable to extract a stream URL from an embed page') 27 | } 28 | 29 | let url = urlMatches[1] 30 | .replace(/[\\/]+/g, '/') // Normalize the slashes... 31 | .replace(/(https?:\/)/, '$1/') // ...but keep the // after "https:" 32 | let qualityMatch = url.match(/\/(\d+p)/i) 33 | let quality = qualityMatch && qualityMatch[1].toLowerCase() 34 | 35 | if (url[0] === '/') { 36 | url = `https:/${url}` 37 | } 38 | 39 | return [{ url, quality }] 40 | } 41 | } 42 | 43 | 44 | export default RedTube 45 | -------------------------------------------------------------------------------- /src/adapters/YouPorn.js: -------------------------------------------------------------------------------- 1 | import HubTrafficAdapter from './HubTrafficAdapter' 2 | 3 | 4 | class YouPorn extends HubTrafficAdapter { 5 | static DISPLAY_NAME = 'YouPorn' 6 | static ITEMS_PER_PAGE = 29 7 | 8 | _makeMethodUrl(method) { 9 | let methodAliases = { 10 | searchVideos: 'search', 11 | getVideoById: 'video_by_id', 12 | } 13 | return `https://www.youporn.com/api/webmasters/${methodAliases[method]}` 14 | } 15 | 16 | _makeEmbedUrl(id) { 17 | return `http://www.youporn.com/embed/${id}` 18 | } 19 | 20 | _extractStreamsFromEmbed(body) { 21 | /* eslint-disable max-len */ 22 | // URL example: 23 | // https:\/\/ee.ypncdn.com\/201709\/01\/14062051\/720p_1500k_14062051\/YouPorn_-_mia-khalifa-big-tits-arab-pornstar-takes-a-fan-s-virginity.mp4?rate=193k&burst=1400k&validfrom=1524765800&validto=1524780200&hash=EGRxkAOZwod648gfnITHeyb%2Fzi8%3D 24 | let regexp = /videoUrl["']?\s*:\s*["']?(https?:\\?\/\\?\/[a-z]+\.ypncdn\.com[^"']+)/gi 25 | /* eslint-enable max-len */ 26 | 27 | let urlMatches = body.match(regexp) 28 | 29 | if (!urlMatches || !urlMatches.length) { 30 | throw new Error('Unable to extract streams from an embed page') 31 | } 32 | 33 | return urlMatches.map((item) => { 34 | let url = item 35 | .match(/http.+/)[0] // Extract the URL 36 | .replace(/[\\/]+/g, '/') // Normalize the slashes... 37 | .replace(/(https?:\/)/, '$1/') // ...but keep the // after "https:" 38 | let qualityMatch = url.match(/\/(\d+p)/i) 39 | let quality = qualityMatch && qualityMatch[1].toLowerCase() 40 | 41 | if (url[0] === '/') { 42 | url = `https:/${url}` 43 | } 44 | 45 | return { url, quality } 46 | }) 47 | } 48 | } 49 | 50 | 51 | export default YouPorn 52 | -------------------------------------------------------------------------------- /tests/adapters/YouPorn/YouPorn.test.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import testAdapter from '../testAdapter' 3 | import YouPorn from '../../../src/adapters/YouPorn' 4 | 5 | 6 | const EMBED_PAGE = readFileSync(`${__dirname}/embeddedMoviePage.html`, 'utf8') 7 | 8 | const ITEMS = [{ 9 | id: '11822513', 10 | type: 'movie', 11 | streams: true, 12 | match: { 13 | name: 'Hot brunette gang bang party. Having fun with Kylie i met via DATES25.COM', 14 | }, 15 | }, { 16 | id: '13745019', 17 | type: 'movie', 18 | streams: true, 19 | match: { 20 | name: 'Teenage Shoplifter Sucks Cock To Avoid Arrest Outrageous Footage', 21 | }, 22 | }] 23 | 24 | 25 | describe('YouPorn', () => { 26 | testAdapter(YouPorn, ITEMS) 27 | 28 | describe('#_extractStreamsFromEmbed()', () => { 29 | test('retrieves a stream from a sample embedded movie page', () => { 30 | let adapter = new YouPorn() 31 | let results = adapter._extractStreamsFromEmbed(EMBED_PAGE) 32 | 33 | expect(results).toEqual([{ 34 | quality: '720p', 35 | url: 'https://ee.ypncdn.com/201709/01/14062051/720p_1500k_14062051/YouPorn_-_mia-khalifa-big-tits-arab-pornstar-takes-a-fan-s-virginity.mp4?rate=193k&burst=1400k&validfrom=1524765800&validto=1524780200&hash=EGRxkAOZwod648gfnITHeyb%2Fzi8%3D', 36 | }, { 37 | quality: '480p', 38 | url: 'https://ee.ypncdn.com/201709/01/14062051/480p_750k_14062051/YouPorn_-_mia-khalifa-big-tits-arab-pornstar-takes-a-fan-s-virginity.mp4?rate=118k&burst=1400k&validfrom=1524765800&validto=1524780200&hash=BPhhTG9iIKFHlZHVJWQtGUuyk9I%3D', 39 | }, { 40 | quality: '240p', 41 | url: 'https://ee.ypncdn.com/201709/01/14062051/240p_240k_14062051/YouPorn_-_mia-khalifa-big-tits-arab-pornstar-takes-a-fan-s-virginity.mp4?rate=59k&burst=1400k&validfrom=1524765800&validto=1524780200&hash=gAETeLQVKHf3uNn3%2FzgV0qv%2BcI0%3D', 42 | }]) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /tests/adapters/SpankWire/SpankWire.test.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import testAdapter from '../testAdapter' 3 | import SpankWire from '../../../src/adapters/SpankWire' 4 | 5 | 6 | const EMBED_PAGE = readFileSync(`${__dirname}/embeddedMoviePage.html`, 'utf8') 7 | 8 | const ITEMS = [{ 9 | id: '16838912', 10 | type: 'movie', 11 | streams: true, 12 | match: { 13 | name: 'Pervert hd first time Did you ever wonder what happens when a red-hot', 14 | }, 15 | }, { 16 | id: '9423892', 17 | type: 'movie', 18 | streams: true, 19 | match: { 20 | name: 'BDSM and Bondage teen slave fucked by master domination', 21 | }, 22 | }] 23 | 24 | 25 | describe('SpankWire', () => { 26 | testAdapter(SpankWire, ITEMS) 27 | 28 | describe('#_extractStreamsFromEmbed()', () => { 29 | test('retrieves a stream from a sample embedded movie page', () => { 30 | let adapter = new SpankWire() 31 | let results = adapter._extractStreamsFromEmbed(EMBED_PAGE) 32 | 33 | expect(results).toEqual([{ 34 | quality: 'Normal', 35 | url: 'https://cdn1-embed-extremetube.spankcdn.net/media//201804/29/24260991/mp4_normal_24260991.mp4?validfrom=1524996113&validto=1525003313&rate=34k&burst=2000k&hash=d3lzXN0Tx0e9%2BId7wp%2Bf1T8Momo%3D', 36 | }, { 37 | quality: 'High', 38 | url: 'https://cdn1-embed-extremetube.spankcdn.net/media//201804/29/24260991/mp4_high_24260991.mp4?validfrom=1524996113&validto=1525003313&rate=43k&burst=2000k&hash=%2FjtfI%2FozorkRmIENVbnsoN2m29c%3D', 39 | }, { 40 | quality: 'Ultra', 41 | url: 'https://cdn1-embed-extremetube.spankcdn.net/media//201804/29/24260991/mp4_ultra_24260991.mp4?validfrom=1524996113&validto=1525003313&rate=65k&burst=2000k&hash=qmx%2BLwYFc3pQRjYmeCKSlNP5ho4%3D', 42 | }, { 43 | quality: '720p', 44 | url: 'https://cdn1-embed-extremetube.spankcdn.net/media//201804/29/24260991/mp4_720p_24260991.mp4?validfrom=1524996113&validto=1525003313&rate=141k&burst=2000k&hash=8lag09lM%2BHc%2F%2Frgi4Kcc6gObcr4%3D', 45 | }]) 46 | }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /dist/adapters/PornHub.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _HubTrafficAdapter = _interopRequireDefault(require("./HubTrafficAdapter")); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 13 | 14 | class PornHub extends _HubTrafficAdapter.default { 15 | _makeMethodUrl(method) { 16 | let methodAliases = { 17 | searchVideos: 'search', 18 | getVideoById: 'video_by_id' 19 | }; 20 | return `https://www.pornhub.com/webmasters/${methodAliases[method]}`; 21 | } 22 | 23 | _makeEmbedUrl(id) { 24 | return `https://www.pornhub.com/embed/${id}`; 25 | } 26 | 27 | _extractStreamsFromEmbed(body) { 28 | /* eslint-disable max-len */ 29 | // URL example: 30 | // https:\/\/de.phncdn.com\/videos\/201503\/28\/46795732\/vl_480_493k_46795732.mp4?ttl=1522227092&ri=1228800&rs=696&hash=268b5f4d76927209ef554ac9e93c6c85 31 | let regexp = /videoUrl["']?\s*:\s*["']?(https?:\\?\/\\?\/[a-z]+\.phncdn\.com[^"']+)/gi; 32 | /* eslint-enable max-len */ 33 | 34 | let urlMatches = regexp.exec(body); 35 | 36 | if (!urlMatches || !urlMatches[1]) { 37 | throw new Error('Unable to extract a stream URL from an embed page'); 38 | } 39 | 40 | let url = urlMatches[1].replace(/[\\/]+/g, '/') // Normalize the slashes... 41 | .replace(/(https?:\/)/, '$1/'); // ...but keep the // after "https:" 42 | 43 | if (url[0] === '/') { 44 | url = `https:/${url}`; 45 | } 46 | 47 | return [{ 48 | url 49 | }]; 50 | } 51 | 52 | } 53 | 54 | _defineProperty(_defineProperty(_defineProperty(PornHub, "DISPLAY_NAME", 'PornHub'), "ITEMS_PER_PAGE", 30), "VIDEO_ID_PARAMETER", 'id'); 55 | 56 | var _default = PornHub; 57 | exports.default = _default; 58 | //# sourceMappingURL=PornHub.js.map -------------------------------------------------------------------------------- /dist/adapters/RedTube.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _HubTrafficAdapter = _interopRequireDefault(require("./HubTrafficAdapter")); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 13 | 14 | class RedTube extends _HubTrafficAdapter.default { 15 | // For some reason Teens doesn't work properly 16 | _makeMethodUrl(method) { 17 | return `https://api.redtube.com?data=redtube.Videos.${method}`; 18 | } 19 | 20 | _makeEmbedUrl(id) { 21 | return `https://embed.redtube.com?id=${id}`; 22 | } 23 | 24 | _extractStreamsFromEmbed(body) { 25 | /* eslint-disable max-len */ 26 | // URL example: 27 | // https://ce.rdtcdn.com/media/videos/201803/12/4930561/480P_600K_4930561.mp4?a5dcae8e1adc0bdaed975f0... 28 | let regexp = /videoUrl["']?\s*:\s*["']?(https?:\\?\/\\?\/[a-z_-]+\.rdtcdn\.com[^"']+)/gi; 29 | /* eslint-enable max-len */ 30 | 31 | let urlMatches = regexp.exec(body); 32 | 33 | if (!urlMatches || !urlMatches[1]) { 34 | throw new Error('Unable to extract a stream URL from an embed page'); 35 | } 36 | 37 | let url = urlMatches[1].replace(/[\\/]+/g, '/') // Normalize the slashes... 38 | .replace(/(https?:\/)/, '$1/'); // ...but keep the // after "https:" 39 | 40 | let qualityMatch = url.match(/\/(\d+p)/i); 41 | let quality = qualityMatch && qualityMatch[1].toLowerCase(); 42 | 43 | if (url[0] === '/') { 44 | url = `https:/${url}`; 45 | } 46 | 47 | return [{ 48 | url, 49 | quality 50 | }]; 51 | } 52 | 53 | } 54 | 55 | _defineProperty(_defineProperty(_defineProperty(RedTube, "DISPLAY_NAME", 'RedTube'), "TAGS_TO_SKIP", ['teens']), "ITEMS_PER_PAGE", 20); 56 | 57 | var _default = RedTube; 58 | exports.default = _default; 59 | //# sourceMappingURL=RedTube.js.map -------------------------------------------------------------------------------- /dist/HttpClient.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../src/HttpClient.js"],"names":["DEFAULT_HEADERS","DEFAULT_REQUEST_OPTIONS","timeout","HttpClient","constructor","options","proxy","host","port","split","agentOptions","secureProxy","baseRequestOptions","agent","http","HttpProxyAgent","https","HttpsProxyAgent","request","url","reqOptions","headers"],"mappings":";;;;;;;AAAA;;AACA;;AACA;;;;;;;;AAGA,MAAMA,kBAAkB;AACtB,gBAAc;AADQ,CAAxB;AAGA,MAAMC,0BAA0B;AAC9BC,WAAS;AADqB,CAAhC;;AAKA,MAAMC,UAAN,CAAiB;AAKfC,cAAYC,UAAU,EAAtB,EAA0B;AAAA,kEAHrBJ,uBAGqB;;AACxB,QAAII,QAAQC,KAAZ,EAAmB;AACjB,UAAI,CAACC,IAAD,EAAOC,IAAP,IAAeH,QAAQC,KAAR,CAAcG,KAAd,CAAoB,GAApB,CAAnB;AACA,UAAIC,eAAe;AAAEH,YAAF;AAAQC,YAAR;AAAcG,qBAAa;AAA3B,OAAnB;AAEA,WAAKC,kBAAL,CAAwBC,KAAxB,GAAgC;AAC9BC,cAAM,IAAIC,uBAAJ,CAAmBL,YAAnB,CADwB;AAE9BM,eAAO,IAAIC,wBAAJ,CAAoBP,YAApB;AAFuB,OAAhC;AAID;AACF;;AAEDQ,UAAQC,GAAR,EAAaC,aAAa,EAA1B,EAA8B;AAC5B,QAAIC,OAAJ;;AAEA,QAAID,WAAWC,OAAf,EAAwB;AACtBA,kCAAerB,eAAf,EAAmCoB,WAAWC,OAA9C;AACD,KAFD,MAEO;AACLA,gBAAUrB,eAAV;AACD;;AAEDoB,mCACK,KAAKR,kBADV,EAEKQ,UAFL;AAGEC;AAHF;AAMA,WAAO,kBAAIF,GAAJ,EAASC,UAAT,CAAP;AACD;;AAjCc;;eAqCFjB,U","sourcesContent":["import got from 'got'\nimport HttpsProxyAgent from 'https-proxy-agent'\nimport HttpProxyAgent from 'http-proxy-agent'\n\n\nconst DEFAULT_HEADERS = {\n 'user-agent': 'stremio-porn',\n}\nconst DEFAULT_REQUEST_OPTIONS = {\n timeout: 20000,\n}\n\n\nclass HttpClient {\n baseRequestOptions = {\n ...DEFAULT_REQUEST_OPTIONS,\n }\n\n constructor(options = {}) {\n if (options.proxy) {\n let [host, port] = options.proxy.split(':')\n let agentOptions = { host, port, secureProxy: true }\n\n this.baseRequestOptions.agent = {\n http: new HttpProxyAgent(agentOptions),\n https: new HttpsProxyAgent(agentOptions),\n }\n }\n }\n\n request(url, reqOptions = {}) {\n let headers\n\n if (reqOptions.headers) {\n headers = { ...DEFAULT_HEADERS, ...reqOptions.headers }\n } else {\n headers = DEFAULT_HEADERS\n }\n\n reqOptions = {\n ...this.baseRequestOptions,\n ...reqOptions,\n headers,\n }\n\n return got(url, reqOptions)\n }\n}\n\n\nexport default HttpClient\n"],"file":"HttpClient.js"} -------------------------------------------------------------------------------- /src/adapters/SpankWire.js: -------------------------------------------------------------------------------- 1 | import HubTrafficAdapter from './HubTrafficAdapter' 2 | 3 | 4 | class SpankWire extends HubTrafficAdapter { 5 | static DISPLAY_NAME = 'SpankWire' 6 | static ITEMS_PER_PAGE = 20 7 | 8 | _makeMethodUrl(method) { 9 | return `https://www.spankwire.com/api/HubTrafficApiCall?data=${method}` 10 | } 11 | 12 | _makeEmbedUrl(id) { 13 | return `https://www.spankwire.com/EmbedPlayer.aspx?ArticleId=${id}` 14 | } 15 | 16 | _extractStreamsFromEmbed(body) { 17 | /* eslint-disable max-len */ 18 | // URL examples: 19 | // \/\/cdn1-embed-spankwire.spankcdn.net\/201505\/13\/1812784\/180P_200k_1812784.mp4?validfrom=1524836136&validto=1524843336&rate=45k&burst=450k&hash=djplLdzje8I9RZWDeUa8EtjK4mw%3D 20 | // \/\/cdn1-embed-extremetube.spankcdn.net\/media\/\/201804\/29\/24260991\/mp4_720p_24260991.mp4?validfrom=1524996113&validto=1525003313&rate=141k&burst=2000k&hash=8lag09lM%2BHc%2F%2Frgi4Kcc6gObcr4%3D 21 | // \/\/cdn1-embed-extremetube.spankcdn.net\/media\/\/201804\/29\/24260991\/mp4_normal_24260991.mp4?validfrom=1524996113&validto=1525003313&rate=34k&burst=2000k&hash=d3lzXN0Tx0e9%2BId7wp%2Bf1T8Momo%3D 22 | /* eslint-enable max-len */ 23 | 24 | let urlRegexp = /playerData.cdnPath\d+\s*=\s*["']?[^"'\s]+["']/gi 25 | let urlMatches = body.match(urlRegexp) 26 | 27 | if (!urlMatches || !urlMatches.length) { 28 | throw new Error('Unable to extract streams from an embed page') 29 | } 30 | 31 | return urlMatches.map((item) => { 32 | let url = item 33 | .match(/["']([^"'\s]+)["']/i)[1] // Extract the URL 34 | .replace(/\\/g, '') // Remove backslashes 35 | 36 | if (url[0] === '/') { 37 | url = `https:${url}` 38 | } 39 | 40 | // Two possible quality formats: "720p" and "high" 41 | let qualityMatch = url.match(/\/(mp4_)?(\d+p|low|normal|high|ultra)/i) 42 | let quality 43 | 44 | if (qualityMatch && qualityMatch[2]) { 45 | quality = qualityMatch[2] 46 | quality = quality[0].toUpperCase() + quality.slice(1).toLowerCase() 47 | } 48 | 49 | return { url, quality } 50 | }) 51 | } 52 | } 53 | 54 | 55 | export default SpankWire 56 | -------------------------------------------------------------------------------- /tests/adapters/Chaturbate/Chaturbate.test.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import testAdapter from '../testAdapter' 3 | import Chaturbate from '../../../src/adapters/Chaturbate' 4 | 5 | 6 | const LIST_PAGE = readFileSync(`${__dirname}/listPage.html`, 'utf8') 7 | const ITEM_PAGE = readFileSync(`${__dirname}/itemPage.html`, 'utf8') 8 | 9 | const ITEMS = [{ 10 | id: 'minksky', 11 | type: 'tv', 12 | streams: true, 13 | match: { 14 | name: 'minksky', 15 | }, 16 | }, { 17 | id: 'fuckable_18', 18 | type: 'tv', 19 | streams: true, 20 | match: { 21 | name: 'fuckable_18', 22 | }, 23 | }] 24 | 25 | 26 | describe('Chaturbate', () => { 27 | testAdapter(Chaturbate, ITEMS) 28 | 29 | describe('#_parseItemPage()', () => { 30 | test('retrieves the item object from the sample item page', () => { 31 | let adapter = new Chaturbate() 32 | let result = adapter._parseItemPage(ITEM_PAGE) 33 | 34 | expect(result).toEqual({ 35 | id: 'fuckable_18', 36 | url: 'https://chaturbate.com/fuckable_18/', 37 | subject: 'Hornyy girls🔥 #lovense ON, help us #cum #squir@ 3goals squirt t // #ohmibod #teen #horny #cum #cream £natural #ohmibod #interactivetoy', 38 | poster: 'https://roomimg.stream.highwebmedia.com/ri/fuckable_18.jpg', 39 | tags: ['lovense', 'cum', 'squir@', 'ohmibod', 'teen', 'horny', 'cum', 'cream', 'ohmibod', 'interactivetoy'], 40 | }) 41 | }) 42 | }) 43 | 44 | describe('#_parseListPage()', () => { 45 | test('retrieves an array of items from the sample list page', () => { 46 | let adapter = new Chaturbate() 47 | let results = adapter._parseListPage(LIST_PAGE) 48 | 49 | expect(results).toHaveLength(72) 50 | expect(results[0]).toEqual({ 51 | id: 'melaniebiche', 52 | url: 'https://chaturbate.com/melaniebiche/', 53 | subject: 'lovense: interactive toy that vibrates with your tips #lovense #ohmibod #interactivetoy #french #hairy #bigass #bbw #bigboobs #bigass #spanish #curvy #natural #aussie', 54 | poster: 'https://roomimg.stream.highwebmedia.com/ri/melaniebiche.jpg?1522108230', 55 | tags: ['lovense', 'ohmibod', 'interactivetoy', 'french', 'hairy', 'bigass', 'bbw', 'bigboobs', 'bigass', 'spanish', 'curvy', 'natural', 'aussie'], 56 | viewers: 361, 57 | }) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /dist/adapters/PornHub.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../../src/adapters/PornHub.js"],"names":["PornHub","HubTrafficAdapter","_makeMethodUrl","method","methodAliases","searchVideos","getVideoById","_makeEmbedUrl","id","_extractStreamsFromEmbed","body","regexp","urlMatches","exec","Error","url","replace"],"mappings":";;;;;;;AAAA;;;;;;AAGA,MAAMA,OAAN,SAAsBC,0BAAtB,CAAwC;AAKtCC,iBAAeC,MAAf,EAAuB;AACrB,QAAIC,gBAAgB;AAClBC,oBAAc,QADI;AAElBC,oBAAc;AAFI,KAApB;AAIA,WAAQ,sCAAqCF,cAAcD,MAAd,CAAsB,EAAnE;AACD;;AAEDI,gBAAcC,EAAd,EAAkB;AAChB,WAAQ,iCAAgCA,EAAG,EAA3C;AACD;;AAEDC,2BAAyBC,IAAzB,EAA+B;AAC7B;AACA;AACA;AACA,QAAIC,SAAS,yEAAb;AACA;;AACA,QAAIC,aAAaD,OAAOE,IAAP,CAAYH,IAAZ,CAAjB;;AAEA,QAAI,CAACE,UAAD,IAAe,CAACA,WAAW,CAAX,CAApB,EAAmC;AACjC,YAAM,IAAIE,KAAJ,CAAU,mDAAV,CAAN;AACD;;AAED,QAAIC,MAAMH,WAAW,CAAX,EACPI,OADO,CACC,SADD,EACY,GADZ,EACiB;AADjB,KAEPA,OAFO,CAEC,aAFD,EAEgB,KAFhB,CAAV,CAZ6B,CAcI;;AAEjC,QAAID,IAAI,CAAJ,MAAW,GAAf,EAAoB;AAClBA,YAAO,UAASA,GAAI,EAApB;AACD;;AAED,WAAO,CAAC;AAAEA;AAAF,KAAD,CAAP;AACD;;AAtCqC;;gDAAlCf,O,kBACkB,S,qBACE,E,yBACI,I;;eAuCfA,O","sourcesContent":["import HubTrafficAdapter from './HubTrafficAdapter'\n\n\nclass PornHub extends HubTrafficAdapter {\n static DISPLAY_NAME = 'PornHub'\n static ITEMS_PER_PAGE = 30\n static VIDEO_ID_PARAMETER = 'id'\n\n _makeMethodUrl(method) {\n let methodAliases = {\n searchVideos: 'search',\n getVideoById: 'video_by_id',\n }\n return `https://www.pornhub.com/webmasters/${methodAliases[method]}`\n }\n\n _makeEmbedUrl(id) {\n return `https://www.pornhub.com/embed/${id}`\n }\n\n _extractStreamsFromEmbed(body) {\n /* eslint-disable max-len */\n // URL example:\n // https:\\/\\/de.phncdn.com\\/videos\\/201503\\/28\\/46795732\\/vl_480_493k_46795732.mp4?ttl=1522227092&ri=1228800&rs=696&hash=268b5f4d76927209ef554ac9e93c6c85\n let regexp = /videoUrl[\"']?\\s*:\\s*[\"']?(https?:\\\\?\\/\\\\?\\/[a-z]+\\.phncdn\\.com[^\"']+)/gi\n /* eslint-enable max-len */\n let urlMatches = regexp.exec(body)\n\n if (!urlMatches || !urlMatches[1]) {\n throw new Error('Unable to extract a stream URL from an embed page')\n }\n\n let url = urlMatches[1]\n .replace(/[\\\\/]+/g, '/') // Normalize the slashes...\n .replace(/(https?:\\/)/, '$1/') // ...but keep the // after \"https:\"\n\n if (url[0] === '/') {\n url = `https:/${url}`\n }\n\n return [{ url }]\n }\n}\n\n\nexport default PornHub\n"],"file":"PornHub.js"} -------------------------------------------------------------------------------- /dist/adapters/YouPorn.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _HubTrafficAdapter = _interopRequireDefault(require("./HubTrafficAdapter")); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 13 | 14 | class YouPorn extends _HubTrafficAdapter.default { 15 | _makeMethodUrl(method) { 16 | let methodAliases = { 17 | searchVideos: 'search', 18 | getVideoById: 'video_by_id' 19 | }; 20 | return `https://www.youporn.com/api/webmasters/${methodAliases[method]}`; 21 | } 22 | 23 | _makeEmbedUrl(id) { 24 | return `http://www.youporn.com/embed/${id}`; 25 | } 26 | 27 | _extractStreamsFromEmbed(body) { 28 | /* eslint-disable max-len */ 29 | // URL example: 30 | // https:\/\/ee.ypncdn.com\/201709\/01\/14062051\/720p_1500k_14062051\/YouPorn_-_mia-khalifa-big-tits-arab-pornstar-takes-a-fan-s-virginity.mp4?rate=193k&burst=1400k&validfrom=1524765800&validto=1524780200&hash=EGRxkAOZwod648gfnITHeyb%2Fzi8%3D 31 | let regexp = /videoUrl["']?\s*:\s*["']?(https?:\\?\/\\?\/[a-z]+\.ypncdn\.com[^"']+)/gi; 32 | /* eslint-enable max-len */ 33 | 34 | let urlMatches = body.match(regexp); 35 | 36 | if (!urlMatches || !urlMatches.length) { 37 | throw new Error('Unable to extract streams from an embed page'); 38 | } 39 | 40 | return urlMatches.map(item => { 41 | let url = item.match(/http.+/)[0] // Extract the URL 42 | .replace(/[\\/]+/g, '/') // Normalize the slashes... 43 | .replace(/(https?:\/)/, '$1/'); // ...but keep the // after "https:" 44 | 45 | let qualityMatch = url.match(/\/(\d+p)/i); 46 | let quality = qualityMatch && qualityMatch[1].toLowerCase(); 47 | 48 | if (url[0] === '/') { 49 | url = `https:/${url}`; 50 | } 51 | 52 | return { 53 | url, 54 | quality 55 | }; 56 | }); 57 | } 58 | 59 | } 60 | 61 | _defineProperty(_defineProperty(YouPorn, "DISPLAY_NAME", 'YouPorn'), "ITEMS_PER_PAGE", 29); 62 | 63 | var _default = YouPorn; 64 | exports.default = _default; 65 | //# sourceMappingURL=YouPorn.js.map -------------------------------------------------------------------------------- /dist/HttpClient.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _got = _interopRequireDefault(require("got")); 9 | 10 | var _httpsProxyAgent = _interopRequireDefault(require("https-proxy-agent")); 11 | 12 | var _httpProxyAgent = _interopRequireDefault(require("http-proxy-agent")); 13 | 14 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 15 | 16 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === 'function') { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function (key) { _defineProperty(target, key, source[key]); }); } return target; } 17 | 18 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 19 | 20 | const DEFAULT_HEADERS = { 21 | 'user-agent': 'stremio-porn' 22 | }; 23 | const DEFAULT_REQUEST_OPTIONS = { 24 | timeout: 20000 25 | }; 26 | 27 | class HttpClient { 28 | constructor(options = {}) { 29 | _defineProperty(this, "baseRequestOptions", _objectSpread({}, DEFAULT_REQUEST_OPTIONS)); 30 | 31 | if (options.proxy) { 32 | let [host, port] = options.proxy.split(':'); 33 | let agentOptions = { 34 | host, 35 | port, 36 | secureProxy: true 37 | }; 38 | this.baseRequestOptions.agent = { 39 | http: new _httpProxyAgent.default(agentOptions), 40 | https: new _httpsProxyAgent.default(agentOptions) 41 | }; 42 | } 43 | } 44 | 45 | request(url, reqOptions = {}) { 46 | let headers; 47 | 48 | if (reqOptions.headers) { 49 | headers = _objectSpread({}, DEFAULT_HEADERS, reqOptions.headers); 50 | } else { 51 | headers = DEFAULT_HEADERS; 52 | } 53 | 54 | reqOptions = _objectSpread({}, this.baseRequestOptions, reqOptions, { 55 | headers 56 | }); 57 | return (0, _got.default)(url, reqOptions); 58 | } 59 | 60 | } 61 | 62 | var _default = HttpClient; 63 | exports.default = _default; 64 | //# sourceMappingURL=HttpClient.js.map -------------------------------------------------------------------------------- /dist/adapters/RedTube.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../../src/adapters/RedTube.js"],"names":["RedTube","HubTrafficAdapter","_makeMethodUrl","method","_makeEmbedUrl","id","_extractStreamsFromEmbed","body","regexp","urlMatches","exec","Error","url","replace","qualityMatch","match","quality","toLowerCase"],"mappings":";;;;;;;AAAA;;;;;;AAGA,MAAMA,OAAN,SAAsBC,0BAAtB,CAAwC;AAEN;AAGhCC,iBAAeC,MAAf,EAAuB;AACrB,WAAQ,+CAA8CA,MAAO,EAA7D;AACD;;AAEDC,gBAAcC,EAAd,EAAkB;AAChB,WAAQ,gCAA+BA,EAAG,EAA1C;AACD;;AAEDC,2BAAyBC,IAAzB,EAA+B;AAC7B;AACA;AACA;AACA,QAAIC,SAAS,2EAAb;AACA;;AACA,QAAIC,aAAaD,OAAOE,IAAP,CAAYH,IAAZ,CAAjB;;AAEA,QAAI,CAACE,UAAD,IAAe,CAACA,WAAW,CAAX,CAApB,EAAmC;AACjC,YAAM,IAAIE,KAAJ,CAAU,mDAAV,CAAN;AACD;;AAED,QAAIC,MAAMH,WAAW,CAAX,EACPI,OADO,CACC,SADD,EACY,GADZ,EACiB;AADjB,KAEPA,OAFO,CAEC,aAFD,EAEgB,KAFhB,CAAV,CAZ6B,CAcI;;AACjC,QAAIC,eAAeF,IAAIG,KAAJ,CAAU,WAAV,CAAnB;AACA,QAAIC,UAAUF,gBAAgBA,aAAa,CAAb,EAAgBG,WAAhB,EAA9B;;AAEA,QAAIL,IAAI,CAAJ,MAAW,GAAf,EAAoB;AAClBA,YAAO,UAASA,GAAI,EAApB;AACD;;AAED,WAAO,CAAC;AAAEA,SAAF;AAAOI;AAAP,KAAD,CAAP;AACD;;AApCqC;;gDAAlChB,O,kBACkB,S,mBACA,CAAC,OAAD,C,qBACE,E;;eAqCXA,O","sourcesContent":["import HubTrafficAdapter from './HubTrafficAdapter'\n\n\nclass RedTube extends HubTrafficAdapter {\n static DISPLAY_NAME = 'RedTube'\n static TAGS_TO_SKIP = ['teens'] // For some reason Teens doesn't work properly\n static ITEMS_PER_PAGE = 20\n\n _makeMethodUrl(method) {\n return `https://api.redtube.com?data=redtube.Videos.${method}`\n }\n\n _makeEmbedUrl(id) {\n return `https://embed.redtube.com?id=${id}`\n }\n\n _extractStreamsFromEmbed(body) {\n /* eslint-disable max-len */\n // URL example:\n // https://ce.rdtcdn.com/media/videos/201803/12/4930561/480P_600K_4930561.mp4?a5dcae8e1adc0bdaed975f0...\n let regexp = /videoUrl[\"']?\\s*:\\s*[\"']?(https?:\\\\?\\/\\\\?\\/[a-z_-]+\\.rdtcdn\\.com[^\"']+)/gi\n /* eslint-enable max-len */\n let urlMatches = regexp.exec(body)\n\n if (!urlMatches || !urlMatches[1]) {\n throw new Error('Unable to extract a stream URL from an embed page')\n }\n\n let url = urlMatches[1]\n .replace(/[\\\\/]+/g, '/') // Normalize the slashes...\n .replace(/(https?:\\/)/, '$1/') // ...but keep the // after \"https:\"\n let qualityMatch = url.match(/\\/(\\d+p)/i)\n let quality = qualityMatch && qualityMatch[1].toLowerCase()\n\n if (url[0] === '/') {\n url = `https:/${url}`\n }\n\n return [{ url, quality }]\n }\n}\n\n\nexport default RedTube\n"],"file":"RedTube.js"} -------------------------------------------------------------------------------- /dist/adapters/SpankWire.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _HubTrafficAdapter = _interopRequireDefault(require("./HubTrafficAdapter")); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 13 | 14 | class SpankWire extends _HubTrafficAdapter.default { 15 | _makeMethodUrl(method) { 16 | return `https://www.spankwire.com/api/HubTrafficApiCall?data=${method}`; 17 | } 18 | 19 | _makeEmbedUrl(id) { 20 | return `https://www.spankwire.com/EmbedPlayer.aspx?ArticleId=${id}`; 21 | } 22 | 23 | _extractStreamsFromEmbed(body) { 24 | /* eslint-disable max-len */ 25 | // URL examples: 26 | // \/\/cdn1-embed-spankwire.spankcdn.net\/201505\/13\/1812784\/180P_200k_1812784.mp4?validfrom=1524836136&validto=1524843336&rate=45k&burst=450k&hash=djplLdzje8I9RZWDeUa8EtjK4mw%3D 27 | // \/\/cdn1-embed-extremetube.spankcdn.net\/media\/\/201804\/29\/24260991\/mp4_720p_24260991.mp4?validfrom=1524996113&validto=1525003313&rate=141k&burst=2000k&hash=8lag09lM%2BHc%2F%2Frgi4Kcc6gObcr4%3D 28 | // \/\/cdn1-embed-extremetube.spankcdn.net\/media\/\/201804\/29\/24260991\/mp4_normal_24260991.mp4?validfrom=1524996113&validto=1525003313&rate=34k&burst=2000k&hash=d3lzXN0Tx0e9%2BId7wp%2Bf1T8Momo%3D 29 | 30 | /* eslint-enable max-len */ 31 | let urlRegexp = /playerData.cdnPath\d+\s*=\s*["']?[^"'\s]+["']/gi; 32 | let urlMatches = body.match(urlRegexp); 33 | 34 | if (!urlMatches || !urlMatches.length) { 35 | throw new Error('Unable to extract streams from an embed page'); 36 | } 37 | 38 | return urlMatches.map(item => { 39 | let url = item.match(/["']([^"'\s]+)["']/i)[1] // Extract the URL 40 | .replace(/\\/g, ''); // Remove backslashes 41 | 42 | if (url[0] === '/') { 43 | url = `https:${url}`; 44 | } // Two possible quality formats: "720p" and "high" 45 | 46 | 47 | let qualityMatch = url.match(/\/(mp4_)?(\d+p|low|normal|high|ultra)/i); 48 | let quality; 49 | 50 | if (qualityMatch && qualityMatch[2]) { 51 | quality = qualityMatch[2]; 52 | quality = quality[0].toUpperCase() + quality.slice(1).toLowerCase(); 53 | } 54 | 55 | return { 56 | url, 57 | quality 58 | }; 59 | }); 60 | } 61 | 62 | } 63 | 64 | _defineProperty(_defineProperty(SpankWire, "DISPLAY_NAME", 'SpankWire'), "ITEMS_PER_PAGE", 20); 65 | 66 | var _default = SpankWire; 67 | exports.default = _default; 68 | //# sourceMappingURL=SpankWire.js.map -------------------------------------------------------------------------------- /dist/adapters/YouPorn.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../../src/adapters/YouPorn.js"],"names":["YouPorn","HubTrafficAdapter","_makeMethodUrl","method","methodAliases","searchVideos","getVideoById","_makeEmbedUrl","id","_extractStreamsFromEmbed","body","regexp","urlMatches","match","length","Error","map","item","url","replace","qualityMatch","quality","toLowerCase"],"mappings":";;;;;;;AAAA;;;;;;AAGA,MAAMA,OAAN,SAAsBC,0BAAtB,CAAwC;AAItCC,iBAAeC,MAAf,EAAuB;AACrB,QAAIC,gBAAgB;AAClBC,oBAAc,QADI;AAElBC,oBAAc;AAFI,KAApB;AAIA,WAAQ,0CAAyCF,cAAcD,MAAd,CAAsB,EAAvE;AACD;;AAEDI,gBAAcC,EAAd,EAAkB;AAChB,WAAQ,gCAA+BA,EAAG,EAA1C;AACD;;AAEDC,2BAAyBC,IAAzB,EAA+B;AAC7B;AACA;AACA;AACA,QAAIC,SAAS,yEAAb;AACA;;AAEA,QAAIC,aAAaF,KAAKG,KAAL,CAAWF,MAAX,CAAjB;;AAEA,QAAI,CAACC,UAAD,IAAe,CAACA,WAAWE,MAA/B,EAAuC;AACrC,YAAM,IAAIC,KAAJ,CAAU,8CAAV,CAAN;AACD;;AAED,WAAOH,WAAWI,GAAX,CAAgBC,IAAD,IAAU;AAC9B,UAAIC,MAAMD,KACPJ,KADO,CACD,QADC,EACS,CADT,EACY;AADZ,OAEPM,OAFO,CAEC,SAFD,EAEY,GAFZ,EAEiB;AAFjB,OAGPA,OAHO,CAGC,aAHD,EAGgB,KAHhB,CAAV,CAD8B,CAIG;;AACjC,UAAIC,eAAeF,IAAIL,KAAJ,CAAU,WAAV,CAAnB;AACA,UAAIQ,UAAUD,gBAAgBA,aAAa,CAAb,EAAgBE,WAAhB,EAA9B;;AAEA,UAAIJ,IAAI,CAAJ,MAAW,GAAf,EAAoB;AAClBA,cAAO,UAASA,GAAI,EAApB;AACD;;AAED,aAAO;AAAEA,WAAF;AAAOG;AAAP,OAAP;AACD,KAbM,CAAP;AAcD;;AA3CqC;;gCAAlCrB,O,kBACkB,S,qBACE,E;;eA6CXA,O","sourcesContent":["import HubTrafficAdapter from './HubTrafficAdapter'\n\n\nclass YouPorn extends HubTrafficAdapter {\n static DISPLAY_NAME = 'YouPorn'\n static ITEMS_PER_PAGE = 29\n\n _makeMethodUrl(method) {\n let methodAliases = {\n searchVideos: 'search',\n getVideoById: 'video_by_id',\n }\n return `https://www.youporn.com/api/webmasters/${methodAliases[method]}`\n }\n\n _makeEmbedUrl(id) {\n return `http://www.youporn.com/embed/${id}`\n }\n\n _extractStreamsFromEmbed(body) {\n /* eslint-disable max-len */\n // URL example:\n // https:\\/\\/ee.ypncdn.com\\/201709\\/01\\/14062051\\/720p_1500k_14062051\\/YouPorn_-_mia-khalifa-big-tits-arab-pornstar-takes-a-fan-s-virginity.mp4?rate=193k&burst=1400k&validfrom=1524765800&validto=1524780200&hash=EGRxkAOZwod648gfnITHeyb%2Fzi8%3D\n let regexp = /videoUrl[\"']?\\s*:\\s*[\"']?(https?:\\\\?\\/\\\\?\\/[a-z]+\\.ypncdn\\.com[^\"']+)/gi\n /* eslint-enable max-len */\n\n let urlMatches = body.match(regexp)\n\n if (!urlMatches || !urlMatches.length) {\n throw new Error('Unable to extract streams from an embed page')\n }\n\n return urlMatches.map((item) => {\n let url = item\n .match(/http.+/)[0] // Extract the URL\n .replace(/[\\\\/]+/g, '/') // Normalize the slashes...\n .replace(/(https?:\\/)/, '$1/') // ...but keep the // after \"https:\"\n let qualityMatch = url.match(/\\/(\\d+p)/i)\n let quality = qualityMatch && qualityMatch[1].toLowerCase()\n\n if (url[0] === '/') {\n url = `https:/${url}`\n }\n\n return { url, quality }\n })\n }\n}\n\n\nexport default YouPorn\n"],"file":"YouPorn.js"} -------------------------------------------------------------------------------- /tests/adapters/testAdapter.js: -------------------------------------------------------------------------------- 1 | import HttpClient from '../../src/HttpClient' 2 | 3 | 4 | function testAdapter(AdapterClass, items = []) { 5 | describe('@integration', () => { 6 | jest.setTimeout(20000) 7 | 8 | let adapter 9 | 10 | beforeEach(() => { 11 | let httpClient = new HttpClient({ 12 | proxy: process.env.STREMIO_PORN_PROXY, 13 | }) 14 | adapter = new AdapterClass(httpClient) 15 | }) 16 | 17 | describe('#find()', () => { 18 | test('when no request query is provided, returns trending items', async () => { 19 | let type = AdapterClass.SUPPORTED_TYPES[0] 20 | let results = await adapter.find({ 21 | query: { type }, 22 | }) 23 | 24 | expect(results.length).toBeGreaterThan(0) 25 | results.forEach((result) => { 26 | expect(result.id).toBeTruthy() 27 | }) 28 | }) 29 | 30 | test('when a search string is provided, returns matching items', async () => { 31 | let search = 'deep' 32 | let limit = 3 33 | let type = AdapterClass.SUPPORTED_TYPES[0] 34 | let results = await adapter.find({ 35 | query: { search, type }, 36 | limit, 37 | }) 38 | 39 | expect(results.length).toBeLessThanOrEqual(limit) 40 | results.forEach((result) => { 41 | expect(result.id).toBeTruthy() 42 | }) 43 | }) 44 | }) 45 | 46 | describe('#getItem()', () => { 47 | items 48 | .filter((item) => item.match) 49 | .forEach(({ id, type, match }) => { 50 | test(`retrieves ${type} ${id}`, async () => { 51 | let query = { type, id } 52 | let [result] = await adapter.getItem({ query }) 53 | 54 | expect(result).toMatchObject(match) 55 | }) 56 | }) 57 | }) 58 | 59 | describe('#getStreams()', () => { 60 | items 61 | .filter((item) => item.streams === true) 62 | .forEach(({ id, type }) => { 63 | test(`doesn't throw for ${type} ${id}`, async () => { 64 | let query = { type, id } 65 | return adapter.getStreams({ query }) 66 | }) 67 | }) 68 | 69 | items 70 | .filter((item) => Array.isArray(item.streams)) 71 | .forEach(({ id, type, streams }) => { 72 | test(`retrieves streams for ${type} ${id}`, async () => { 73 | let query = { type, id } 74 | let results = await adapter.getStreams({ query }) 75 | 76 | expect(results).toHaveLength(streams.length) 77 | streams.forEach((stream) => { 78 | let includesStream = Boolean(results.find((result) => { 79 | return result.url.includes(stream) 80 | })) 81 | expect(includesStream).toBe(true) 82 | }) 83 | }) 84 | }) 85 | }) 86 | }) 87 | } 88 | 89 | export default testAdapter 90 | -------------------------------------------------------------------------------- /dist/adapters/SpankWire.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../../src/adapters/SpankWire.js"],"names":["SpankWire","HubTrafficAdapter","_makeMethodUrl","method","_makeEmbedUrl","id","_extractStreamsFromEmbed","body","urlRegexp","urlMatches","match","length","Error","map","item","url","replace","qualityMatch","quality","toUpperCase","slice","toLowerCase"],"mappings":";;;;;;;AAAA;;;;;;AAGA,MAAMA,SAAN,SAAwBC,0BAAxB,CAA0C;AAIxCC,iBAAeC,MAAf,EAAuB;AACrB,WAAQ,wDAAuDA,MAAO,EAAtE;AACD;;AAEDC,gBAAcC,EAAd,EAAkB;AAChB,WAAQ,wDAAuDA,EAAG,EAAlE;AACD;;AAEDC,2BAAyBC,IAAzB,EAA+B;AAC7B;AACA;AACA;AACA;AACA;;AACA;AAEA,QAAIC,YAAY,iDAAhB;AACA,QAAIC,aAAaF,KAAKG,KAAL,CAAWF,SAAX,CAAjB;;AAEA,QAAI,CAACC,UAAD,IAAe,CAACA,WAAWE,MAA/B,EAAuC;AACrC,YAAM,IAAIC,KAAJ,CAAU,8CAAV,CAAN;AACD;;AAED,WAAOH,WAAWI,GAAX,CAAgBC,IAAD,IAAU;AAC9B,UAAIC,MAAMD,KACPJ,KADO,CACD,qBADC,EACsB,CADtB,EACyB;AADzB,OAEPM,OAFO,CAEC,KAFD,EAEQ,EAFR,CAAV,CAD8B,CAGR;;AAEtB,UAAID,IAAI,CAAJ,MAAW,GAAf,EAAoB;AAClBA,cAAO,SAAQA,GAAI,EAAnB;AACD,OAP6B,CAS9B;;;AACA,UAAIE,eAAeF,IAAIL,KAAJ,CAAU,wCAAV,CAAnB;AACA,UAAIQ,OAAJ;;AAEA,UAAID,gBAAgBA,aAAa,CAAb,CAApB,EAAqC;AACnCC,kBAAUD,aAAa,CAAb,CAAV;AACAC,kBAAUA,QAAQ,CAAR,EAAWC,WAAX,KAA2BD,QAAQE,KAAR,CAAc,CAAd,EAAiBC,WAAjB,EAArC;AACD;;AAED,aAAO;AAAEN,WAAF;AAAOG;AAAP,OAAP;AACD,KAnBM,CAAP;AAoBD;;AA/CuC;;gCAApClB,S,kBACkB,W,qBACE,E;;eAiDXA,S","sourcesContent":["import HubTrafficAdapter from './HubTrafficAdapter'\n\n\nclass SpankWire extends HubTrafficAdapter {\n static DISPLAY_NAME = 'SpankWire'\n static ITEMS_PER_PAGE = 20\n\n _makeMethodUrl(method) {\n return `https://www.spankwire.com/api/HubTrafficApiCall?data=${method}`\n }\n\n _makeEmbedUrl(id) {\n return `https://www.spankwire.com/EmbedPlayer.aspx?ArticleId=${id}`\n }\n\n _extractStreamsFromEmbed(body) {\n /* eslint-disable max-len */\n // URL examples:\n // \\/\\/cdn1-embed-spankwire.spankcdn.net\\/201505\\/13\\/1812784\\/180P_200k_1812784.mp4?validfrom=1524836136&validto=1524843336&rate=45k&burst=450k&hash=djplLdzje8I9RZWDeUa8EtjK4mw%3D\n // \\/\\/cdn1-embed-extremetube.spankcdn.net\\/media\\/\\/201804\\/29\\/24260991\\/mp4_720p_24260991.mp4?validfrom=1524996113&validto=1525003313&rate=141k&burst=2000k&hash=8lag09lM%2BHc%2F%2Frgi4Kcc6gObcr4%3D\n // \\/\\/cdn1-embed-extremetube.spankcdn.net\\/media\\/\\/201804\\/29\\/24260991\\/mp4_normal_24260991.mp4?validfrom=1524996113&validto=1525003313&rate=34k&burst=2000k&hash=d3lzXN0Tx0e9%2BId7wp%2Bf1T8Momo%3D\n /* eslint-enable max-len */\n\n let urlRegexp = /playerData.cdnPath\\d+\\s*=\\s*[\"']?[^\"'\\s]+[\"']/gi\n let urlMatches = body.match(urlRegexp)\n\n if (!urlMatches || !urlMatches.length) {\n throw new Error('Unable to extract streams from an embed page')\n }\n\n return urlMatches.map((item) => {\n let url = item\n .match(/[\"']([^\"'\\s]+)[\"']/i)[1] // Extract the URL\n .replace(/\\\\/g, '') // Remove backslashes\n\n if (url[0] === '/') {\n url = `https:${url}`\n }\n\n // Two possible quality formats: \"720p\" and \"high\"\n let qualityMatch = url.match(/\\/(mp4_)?(\\d+p|low|normal|high|ultra)/i)\n let quality\n\n if (qualityMatch && qualityMatch[2]) {\n quality = qualityMatch[2]\n quality = quality[0].toUpperCase() + quality.slice(1).toLowerCase()\n }\n\n return { url, quality }\n })\n }\n}\n\n\nexport default SpankWire\n"],"file":"SpankWire.js"} -------------------------------------------------------------------------------- /tests/adapters/PornCom/PornCom.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | import { readFileSync } from 'fs' 4 | import testAdapter from '../testAdapter' 5 | import PornCom from '../../../src/adapters/PornCom' 6 | 7 | 8 | const API_RESPONSE = readFileSync(`${__dirname}/apiResponse.xml`, 'utf8') 9 | const EMBED_PAGE = readFileSync(`${__dirname}/embedPage.xml`, 'utf8') 10 | 11 | const ITEMS = [{ 12 | id: '4163325', 13 | type: 'movie', 14 | streams: true, 15 | match: { 16 | name: 'TS Casey Kisses threesome with lesbians', 17 | runtime: '5:40', 18 | website: 'https://www.porn.com/videos/ts-casey-kisses-threesome-with-lesbians-4163325', 19 | genre: [ 20 | 'Amateur', 'Anal Sex', 'Couples', 'Hardcore', 'HD', 'Licking', 'Pussy Licking', 21 | 'Reverse Cowgirl', 'Straight', 'Strap-On', 'White Female', 22 | ], 23 | }, 24 | }, { 25 | id: '2212517', 26 | type: 'movie', 27 | streams: true, 28 | match: { 29 | name: 'Cameraman shoves huge cock in agent', 30 | runtime: '10:04', 31 | website: 'https://www.porn.com/videos/cameraman-shoves-huge-cock-in-agent-2212517', 32 | genre: [ 33 | 'Amateur', 'Ass Spreading', 'Beautiful', 'Big Cock', 'Blowjob', 'Bra', 34 | 'Brunette', 'Casting', 'College', 'Couch', 'Couples', 'Czech', 'Deep Throat', 35 | 'Doggy Style', 'Dress', 'Euro', 'Fingering', 'HD', 'Hardcore', 'High Heels', 36 | 'Masturbation', 'Office', 'POV', 'Panties - Other', 'Pussy Fingering', 37 | 'Pussy Masturbation', 'Pussy Spreading', 'Reality', 'Shaved Pussy', 'Skinny', 38 | 'Small Tits', 'Straight', 'Strap-On', 'Sucking', 'Toys', 'Vaginal Toys', 39 | 'Voyeur', 'White Female', 'White Male', 40 | ], 41 | }, 42 | }] 43 | 44 | 45 | describe('PornCom', () => { 46 | testAdapter(PornCom, ITEMS) 47 | 48 | describe('#_extractQualitiesFromEmbedPage()', () => { 49 | test('retrieves an array of quality strings from the sample embed page', () => { 50 | let adapter = new PornCom() 51 | let results = adapter._extractQualitiesFromEmbedPage(EMBED_PAGE) 52 | 53 | expect(results).toEqual(['144', '240']) 54 | }) 55 | }) 56 | 57 | describe('#_parseApiResponse()', () => { 58 | test('retrieves an array of items from the sample API response', () => { 59 | let adapter = new PornCom() 60 | let results = adapter._parseApiResponse(API_RESPONSE) 61 | 62 | expect(results).toHaveLength(70) 63 | expect(results[0]).toEqual({ 64 | id: 4451515, 65 | url: 'https://www.porn.com/videos/homemade-anal-party-milf-ass-treatment-4451515', 66 | active_date: '2018-04-11 13:04:55', 67 | thumb: 'https://i-e-cdn.porn.com/sc/4/4451/4451515/promo/crop/380x222/promo_3.jpg', 68 | title: 'Homemade anal : party Milf ass treatment', 69 | duration: 389, 70 | views: 0, 71 | ratings: 0, 72 | rating: 0, 73 | channel: null, 74 | actors: [], 75 | tags: [ 76 | 'Anal', 'Ass Fingering', 'Ass Spreading', 'Blonde', 'Couples', 'Fingering', 77 | 'Hardcore', 'Homemade', 'Oil / Lotion', 'POV', 'Panties - Other', 78 | 'Small Cocks', 'Straight', 'White Female', 'White Male', 79 | ], 80 | embed_url: 'https://www.porn.com/videos/embed/4451515', 81 | embed_html: '', 82 | }) 83 | }) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /src/adapters/PornCom.js: -------------------------------------------------------------------------------- 1 | import BaseAdapter from './BaseAdapter' 2 | 3 | 4 | const BASE_URL = 'https://www.porn.com' 5 | const API_URL = 'https://api.porn.com' 6 | const VIDEOS_API_URL = `${API_URL}/videos/find.json` 7 | const ITEMS_PER_PAGE = 70 8 | const SUPPORTED_TYPES = ['movie'] 9 | 10 | 11 | function formatDuration(seconds) { 12 | seconds = Number(seconds) 13 | let minutesString = Math.floor(seconds / 60) 14 | let secondsString = `0${seconds % 60}`.slice(-2) 15 | return `${minutesString}:${secondsString}` 16 | } 17 | 18 | 19 | class PornCom extends BaseAdapter { 20 | static DISPLAY_NAME = 'Porn.com' 21 | static SUPPORTED_TYPES = SUPPORTED_TYPES 22 | static ITEMS_PER_PAGE = ITEMS_PER_PAGE 23 | 24 | _normalizeItem(item) { 25 | return super._normalizeItem({ 26 | type: 'movie', 27 | id: item.id, 28 | name: item.title, 29 | genre: item.tags, 30 | banner: item.thumb, 31 | poster: item.thumb, 32 | posterShape: 'landscape', 33 | website: item.url, 34 | description: item.url, 35 | runtime: item.duration ? formatDuration(item.duration) : undefined, 36 | year: new Date(item.active_date).getFullYear(), 37 | popularity: item.views && Number(item.views), 38 | isFree: 1, 39 | }) 40 | } 41 | 42 | _normalizeStream(stream) { 43 | return super._normalizeStream({ 44 | id: stream.id, 45 | url: stream.url, 46 | title: `${stream.quality}p`, 47 | availability: 1, 48 | live: true, 49 | isFree: true, 50 | }) 51 | } 52 | 53 | _makeEmbedUrl(id) { 54 | return `${BASE_URL}/videos/embed/${id}` 55 | } 56 | 57 | _makeDownloadUrl(id, quality) { 58 | return `${BASE_URL}/download/${quality}/${id}.mp4` 59 | } 60 | 61 | _parseApiResponse(response) { 62 | if (typeof response === 'string') { 63 | response = JSON.parse(response) 64 | } 65 | 66 | if (!response.success) { 67 | throw new Error(response.message) 68 | } 69 | 70 | return response.result 71 | } 72 | 73 | _extractQualitiesFromEmbedPage(body) { 74 | return body 75 | .match(/['"]?id['"]?:\s*['"]\d+p['"]/gi) // Find id:"240p" 76 | .map((item) => item.match(/\d+/)[0]) // Extract 240 77 | .filter((quality) => Number(quality) < 360) // 360+ are restricted 78 | } 79 | 80 | async _getQualities(id) { 81 | let embedUrl = this._makeEmbedUrl(id) 82 | let { body } = await this.httpClient.request(embedUrl) 83 | return this._extractQualitiesFromEmbedPage(body) 84 | } 85 | 86 | async _findByPage(query, page) { 87 | let options = { 88 | json: true, 89 | query: { 90 | page, 91 | limit: ITEMS_PER_PAGE, 92 | search: query.search, 93 | cats: query.genre, 94 | }, 95 | } 96 | let { body } = await this.httpClient.request(VIDEOS_API_URL, options) 97 | return this._parseApiResponse(body) 98 | } 99 | 100 | async _getItem(type, id) { 101 | let options = { 102 | json: true, 103 | query: { id, limit: 1 }, 104 | } 105 | let { body } = await this.httpClient.request(VIDEOS_API_URL, options) 106 | return this._parseApiResponse(body)[0] 107 | } 108 | 109 | async _getStreams(type, id) { 110 | let qualities = await this._getQualities(id) 111 | return qualities.map((quality) => { 112 | let url = this._makeDownloadUrl(id, quality) 113 | return { id, url, quality } 114 | }) 115 | } 116 | } 117 | 118 | 119 | export default PornCom 120 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stremio-porn", 3 | "version": "0.0.4", 4 | "description": "Stremio addon that provides videos and webcam streams from various porn sites", 5 | "author": "Naughty Doge ", 6 | "scripts": { 7 | "start": "node dist/index.js", 8 | "dev": "cross-env NODE_ENV=development node --inspect dist/index.js", 9 | "prod": "cross-env NODE_ENV=production node dist/index.js", 10 | "build": "rimraf dist/**/* && babel src -d dist --source-maps", 11 | "docker-start": "cross-env-shell STREMIO_PORN_PORT?=80 docker run --rm -p $STREMIO_PORN_PORT:$STREMIO_PORN_PORT -e NODE_ENV -e STREMIO_PORN_ID -e STREMIO_PORN_ENDPOINT -e STREMIO_PORN_PORT -e STREMIO_PORN_EMAIL -e STREMIO_PORN_CACHE -e STREMIO_PORN_PROXY --name=stremio-porn stremio-porn", 12 | "docker-dev": "cross-env-shell NODE_ENV=development STREMIO_PORN_PORT?=80 docker run --rm -p $STREMIO_PORN_PORT:$STREMIO_PORN_PORT -e NODE_ENV -e STREMIO_PORN_ID -e STREMIO_PORN_ENDPOINT -e STREMIO_PORN_PORT -e STREMIO_PORN_EMAIL -e STREMIO_PORN_CACHE -e STREMIO_PORN_PROXY --name=stremio-porn stremio-porn", 13 | "docker-prod": "cross-env-shell NODE_ENV=production STREMIO_PORN_PORT?=80 docker run --rm -p $STREMIO_PORN_PORT:$STREMIO_PORN_PORT -e NODE_ENV -e STREMIO_PORN_ID -e STREMIO_PORN_ENDPOINT -e STREMIO_PORN_PORT -e STREMIO_PORN_EMAIL -e STREMIO_PORN_CACHE -e STREMIO_PORN_PROXY --name=stremio-porn stremio-porn", 14 | "docker-stop": "docker stop stremio-porn", 15 | "docker-build": "docker build -t stremio-porn .", 16 | "test-unit": "jest --testNamePattern=\"^(?!.*@integration).*$\"", 17 | "test-integration": "jest --testNamePattern=\"^.*@integration.*$\"", 18 | "test": "jest", 19 | "precommit": "lint-staged" 20 | }, 21 | "lint-staged": { 22 | "(src|tests)/**/*.js": [ 23 | "eslint --fix", 24 | "git add" 25 | ] 26 | }, 27 | "jest": { 28 | "testEnvironment": "node", 29 | "testRegex": "tests/.*\\.test\\.js$", 30 | "forceExit": true 31 | }, 32 | "babel": { 33 | "presets": [ 34 | [ 35 | "@babel/preset-env", 36 | { 37 | "targets": { 38 | "node": "6.11.5" 39 | } 40 | } 41 | ] 42 | ], 43 | "plugins": [ 44 | "@babel/plugin-proposal-class-properties", 45 | "@babel/plugin-syntax-object-rest-spread", 46 | [ 47 | "@babel/plugin-proposal-object-rest-spread", 48 | { 49 | "useBuiltIns": true 50 | } 51 | ] 52 | ] 53 | }, 54 | "engines": { 55 | "node": ">=6.11.5" 56 | }, 57 | "devDependencies": { 58 | "@babel/cli": "7.0.0-beta.46", 59 | "@babel/core": "7.0.0-beta.46", 60 | "@babel/plugin-proposal-class-properties": "7.0.0-beta.46", 61 | "@babel/plugin-proposal-object-rest-spread": "7.0.0-beta.46", 62 | "@babel/plugin-syntax-object-rest-spread": "7.0.0-beta.46", 63 | "@babel/preset-env": "7.0.0-beta.46", 64 | "babel-core": "^7.0.0-bridge.0", 65 | "babel-eslint": "^8.2.3", 66 | "babel-jest": "^22.4.3", 67 | "eslint": "^4.18.1", 68 | "eslint-plugin-import": "^2.11.0", 69 | "eslint-plugin-jest": "^21.15.1", 70 | "husky": "^0.14.3", 71 | "jest": "^22.4.3", 72 | "lint-staged": "^7.0.5", 73 | "rimraf": "^2.6.2" 74 | }, 75 | "dependencies": { 76 | "bottleneck": "^2.3.1", 77 | "cache-manager": "^2.9.0", 78 | "cache-manager-redis-store": "^1.4.0", 79 | "chalk": "^2.4.1", 80 | "cheerio": "^1.0.0-rc.2", 81 | "cross-env-default": "^5.1.3-1", 82 | "got": "^8.3.0", 83 | "http-proxy-agent": "^2.1.0", 84 | "https-proxy-agent": "^2.2.1", 85 | "serve-static": "^1.13.2", 86 | "stremio-addons": "^2.8.14", 87 | "xml-js": "^1.6.2" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/adapters/Chaturbate.js: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio' 2 | import BaseAdapter from './BaseAdapter' 3 | 4 | 5 | const BASE_URL = 'https://chaturbate.com' 6 | const GET_STREAM_URL = 'https://chaturbate.com/get_edge_hls_url_ajax/' 7 | // Chaturbate's number of items per page varies from load to load, 8 | // so this is the minimum number 9 | const ITEMS_PER_PAGE = 60 10 | const SUPPORTED_TYPES = ['tv'] 11 | 12 | 13 | class Chaturbate extends BaseAdapter { 14 | static DISPLAY_NAME = 'Chaturbate' 15 | static SUPPORTED_TYPES = SUPPORTED_TYPES 16 | static ITEMS_PER_PAGE = ITEMS_PER_PAGE 17 | 18 | _normalizeItem(item) { 19 | return super._normalizeItem({ 20 | type: 'tv', 21 | id: item.id, 22 | name: item.id, 23 | genre: item.tags, 24 | banner: item.poster, 25 | poster: item.poster, 26 | posterShape: 'landscape', 27 | website: item.url, 28 | description: item.subject, 29 | popularity: item.viewers, 30 | isFree: true, 31 | }) 32 | } 33 | 34 | _normalizeStream(stream) { 35 | return super._normalizeStream({ 36 | ...stream, 37 | title: 'Watch', 38 | availability: 1, 39 | live: true, 40 | isFree: true, 41 | }) 42 | } 43 | 44 | _parseListPage(body) { 45 | let $ = cheerio.load(body) 46 | let tagRegexp = /#\S+/g 47 | return $('.list > li').map((i, item) => { 48 | let $item = $(item) 49 | let $link = $item.find('.title > a') 50 | let id = $link.text().trim() 51 | let url = BASE_URL + $link.attr('href') 52 | let subject = $item.find('.subject').text().trim() 53 | let tags = (subject.match(tagRegexp) || []).map((tag) => tag.slice(1)) 54 | let poster = $item.find('img').attr('src') 55 | let viewers = $item.find('.cams').text().match(/(\d+) viewers/i) 56 | viewers = viewers && Number(viewers[1]) 57 | return { id, url, subject, poster, tags, viewers } 58 | }).toArray() 59 | } 60 | 61 | _parseItemPage(body) { 62 | let $ = cheerio.load(body) 63 | let tagRegexp = /#\S+/g 64 | let url = $('meta[property="og:url"]').attr('content') 65 | let id = url.split('/').slice(-2, -1)[0] 66 | let subject = $('meta[property="og:description"]').attr('content').trim() 67 | let tags = (subject.match(tagRegexp) || []).map((tag) => tag.slice(1)) 68 | let poster = $('meta[property="og:image"]').attr('content') 69 | return { id, url, subject, poster, tags } 70 | } 71 | 72 | async _findByPage(query, page) { 73 | let options = { 74 | query: { 75 | page, 76 | keywords: query.search, 77 | }, 78 | } 79 | let url = query.genre ? `${BASE_URL}/tag/${query.genre}` : BASE_URL 80 | let { body } = await this.httpClient.request(url, options) 81 | return this._parseListPage(body) 82 | } 83 | 84 | async _getItem(type, id) { 85 | let url = `${BASE_URL}/${id}` 86 | let { body } = await this.httpClient.request(url) 87 | return this._parseItemPage(body) 88 | } 89 | 90 | async _getStreams(type, id) { 91 | let options = { 92 | form: true, 93 | json: true, 94 | method: 'post', 95 | headers: { 96 | 'Content-Type': 'application/x-www-form-urlencoded', 97 | 'X-Requested-With': 'XMLHttpRequest', 98 | Referer: `${BASE_URL}/${id}`, 99 | }, 100 | body: { 101 | /* eslint-disable-next-line camelcase */ 102 | room_slug: id, 103 | bandwidth: 'high', 104 | }, 105 | } 106 | let { body } = await this.httpClient.request(GET_STREAM_URL, options) 107 | 108 | if (body.success && body.room_status === 'public') { 109 | return [{ id, url: body.url }] 110 | } else { 111 | return [] 112 | } 113 | } 114 | } 115 | 116 | 117 | export default Chaturbate 118 | -------------------------------------------------------------------------------- /src/adapters/BaseAdapter.js: -------------------------------------------------------------------------------- 1 | import Bottleneck from 'bottleneck' 2 | 3 | 4 | // Contains some common methods as well as public wrappers 5 | // that prepare requests, redirect them to private methods 6 | // and normalize results 7 | class BaseAdapter { 8 | static SUPPORTED_TYPES = [] 9 | static MAX_RESULTS_PER_REQUEST = 100 10 | static MAX_CONCURRENT_REQUESTS = 3 11 | 12 | constructor(httpClient) { 13 | this.httpClient = httpClient 14 | this.scheduler = new Bottleneck({ 15 | maxConcurrent: this.constructor.MAX_CONCURRENT_REQUESTS, 16 | }) 17 | } 18 | 19 | _normalizeItem(item) { 20 | return item 21 | } 22 | 23 | _normalizeStream(stream) { 24 | if (stream.name) { 25 | return stream 26 | } else { 27 | return { ...stream, name: this.constructor.name } 28 | } 29 | } 30 | 31 | _paginate(request) { 32 | let itemsPerPage = this.constructor.ITEMS_PER_PAGE || Infinity 33 | let { skip = 0, limit = itemsPerPage } = request 34 | limit = Math.min(limit, this.constructor.MAX_RESULTS_PER_REQUEST) 35 | itemsPerPage = Math.min(itemsPerPage, limit) 36 | 37 | let firstPage = Math.ceil((skip + 0.1) / itemsPerPage) || 1 38 | let pageCount = Math.ceil(limit / itemsPerPage) 39 | let pages = [] 40 | 41 | for (let i = firstPage; pages.length < pageCount; i++) { 42 | pages.push(i) 43 | } 44 | 45 | return { 46 | pages, skip, limit, 47 | skipOnFirstPage: skip % itemsPerPage, 48 | } 49 | } 50 | 51 | _validateRequest(request, typeRequired) { 52 | let { SUPPORTED_TYPES } = this.constructor 53 | 54 | if (typeof request !== 'object') { 55 | throw new Error(`A request must be an object, ${typeof request} given`) 56 | } 57 | 58 | if (!request.query) { 59 | throw new Error('Request query must not be empty') 60 | } 61 | 62 | if (typeRequired && !request.query.type) { 63 | throw new Error('Content type must be specified') 64 | } 65 | 66 | if (request.query.type && !SUPPORTED_TYPES.includes(request.query.type)) { 67 | throw new Error(`Content type ${request.query.type} is not supported`) 68 | } 69 | } 70 | 71 | async _find(query, pagination) { 72 | let { pages, limit, skipOnFirstPage } = pagination 73 | let requests = pages.map((page) => { 74 | return this._findByPage(query, page) 75 | }) 76 | 77 | let results = await Promise.all(requests) 78 | results = [].concat(...results).filter((item) => item) 79 | return results.slice(skipOnFirstPage, skipOnFirstPage + limit) 80 | } 81 | 82 | async find(request) { 83 | this._validateRequest(request) 84 | 85 | let pagination = this._paginate(request) 86 | let { query } = request 87 | 88 | if (!query.type) { 89 | query = { 90 | ...query, 91 | type: this.constructor.SUPPORTED_TYPES[0], 92 | } 93 | } 94 | 95 | let results = await this.scheduler.schedule(() => { 96 | return this._find(query, pagination) 97 | }) 98 | 99 | if (results) { 100 | return results.map((item) => this._normalizeItem(item)) 101 | } else { 102 | return [] 103 | } 104 | } 105 | 106 | async getItem(request) { 107 | this._validateRequest(request, true) 108 | 109 | let { type, id } = request.query 110 | let result = await this.scheduler.schedule(() => { 111 | return this._getItem(type, id) 112 | }) 113 | return result ? [this._normalizeItem(result)] : [] 114 | } 115 | 116 | async getStreams(request) { 117 | this._validateRequest(request, true) 118 | 119 | let { type, id } = request.query 120 | let results = await this.scheduler.schedule(() => { 121 | return this._getStreams(type, id) 122 | }) 123 | 124 | if (results) { 125 | return results.map((stream) => this._normalizeStream(stream)) 126 | } else { 127 | return [] 128 | } 129 | } 130 | } 131 | 132 | 133 | export default BaseAdapter 134 | -------------------------------------------------------------------------------- /tests/adapters/EPorner/EPorner.test.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import testAdapter from '../testAdapter' 3 | import EPorner from '../../../src/adapters/EPorner' 4 | 5 | 6 | const API_RESPONSE = readFileSync(`${__dirname}/apiResponse.xml`, 'utf8') 7 | const MOVIE_PAGE = readFileSync(`${__dirname}/moviePage.html`, 'utf8') 8 | 9 | const ITEMS = [{ 10 | id: '6NQ6SyoGpTm', 11 | type: 'movie', 12 | streams: true, 13 | match: { 14 | name: 'Creampie After Pool Fucking', 15 | runtime: '21:00', 16 | website: 'https://www.eporner.com/hd-porn/6NQ6SyoGpTm/Creampie-After-Pool-Fucking/', 17 | genre: [ 18 | 'Blonde', 'Creampie', 'Cumshot', 'Hardcore', 'Pov', 'Outdoor', 'Teens', 19 | 'Jessie', 'Wylde', 20 | ], 21 | }, 22 | }, { 23 | id: '5uZP8UIKyTS', 24 | type: 'movie', 25 | streams: true, 26 | match: { 27 | name: 'Two Naked Teens', 28 | runtime: '8:15', 29 | website: 'https://www.eporner.com/hd-porn/5uZP8UIKyTS/Two-Naked-Teens/', 30 | genre: [ 31 | 'Blonde', 'Brunette', 'Masturbation', 'Striptease', 32 | 'Teens', 'Small', 'Tits', 'Webcam', 33 | ], 34 | }, 35 | }] 36 | 37 | 38 | describe('EPorner', () => { 39 | testAdapter(EPorner, ITEMS) 40 | 41 | describe('#_parseMoviePage()', () => { 42 | test('retrieves the item object from the sample movie page', () => { 43 | let adapter = new EPorner() 44 | let result = adapter._parseMoviePage(MOVIE_PAGE) 45 | 46 | expect(result).toEqual({ 47 | _source: 'moviePage', 48 | title: 'Amateur Blonde With Big Boobs Takes Cock', 49 | url: 'https://www.eporner.com/hd-porn/byEk66VS4ez/Amateur-Blonde-With-Big-Boobs-Takes-Cock/', 50 | duration: '31:31', 51 | image: 'https://static-eu-cdn.eporner.com/thumbs/static4/1/15/154/1547921/7_240.jpg', 52 | tags: ['Blonde', 'Big', 'Tits', 'Cumshot', 'Hardcore', 'Pov', 'Public'], 53 | downloadUrls: [ 54 | 'https://www.eporner.com/dload/byEk66VS4ez/240/1547921-240p.mp4', 55 | 'https://www.eporner.com/dload/byEk66VS4ez/360/1547921-360p.mp4', 56 | 'https://www.eporner.com/dload/byEk66VS4ez/480/1547921-480p.mp4', 57 | 'https://www.eporner.com/dload/byEk66VS4ez/720/1547921-720p.mp4', 58 | 'https://www.eporner.com/dload/byEk66VS4ez/1080/1547921-1080p.mp4', 59 | ], 60 | }) 61 | }) 62 | }) 63 | 64 | describe('#_parseApiResponse()', () => { 65 | test('retrieves an array of items from the sample API response', () => { 66 | let adapter = new EPorner() 67 | let results = adapter._parseApiResponse(API_RESPONSE) 68 | 69 | expect(results).toHaveLength(10) 70 | expect(results[0]).toEqual({ 71 | id: { 72 | _text: '1562275', 73 | }, 74 | sid: { 75 | _text: 'EjHSX22iLQp', 76 | }, 77 | title: { 78 | _text: 'Teen With A Hairy Pussy Gets Pounded Hard', 79 | }, 80 | keywords: { 81 | _text: ', teens, blonde, amateur, homemade, Teen with a hairy pussy gets pounded hard, cumshot, hardcore, lingerie', 82 | }, 83 | views: { 84 | _text: '9236', 85 | }, 86 | loc: { 87 | _text: 'https://www.eporner.com/hd-porn/EjHSX22iLQp/Teen-With-A-Hairy-Pussy-Gets-Pounded-Hard/', 88 | }, 89 | imgthumb: { 90 | _text: 'https://static-eu-cdn.eporner.com/thumbs/static4/1/15/156/1562275/14.jpg', 91 | }, 92 | imgthumb320x240: { 93 | _text: 'https://imggen.eporner.com/1562275/320/240/14.jpg', 94 | }, 95 | added: {}, 96 | added2: {}, 97 | lenghtsec: { 98 | _text: '554', 99 | }, 100 | lenghtmin: { 101 | _text: '9:14', 102 | }, 103 | embed: { 104 | _cdata: '', 105 | }, 106 | }) 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /src/adapters/HubTrafficAdapter.js: -------------------------------------------------------------------------------- 1 | import { URL } from 'url' 2 | import BaseAdapter from './BaseAdapter' 3 | 4 | 5 | // https://www.hubtraffic.com/ 6 | class HubTrafficAdapter extends BaseAdapter { 7 | static SUPPORTED_TYPES = ['movie'] 8 | static TAGS_TO_SKIP = [] 9 | static VIDEO_ID_PARAMETER = 'video_id' 10 | 11 | _normalizeItem(item) { 12 | let video = item.video || item 13 | let { TAGS_TO_SKIP } = this.constructor 14 | let tags = video.tags && Object.values(video.tags) 15 | .map((tag) => { 16 | return (typeof tag === 'string') ? tag : tag.tag_name 17 | }) 18 | .filter((tag) => !TAGS_TO_SKIP.includes(tag.toLowerCase())) 19 | 20 | return super._normalizeItem({ 21 | type: 'movie', 22 | id: video.video_id || video.id, 23 | name: video.title.trim(), 24 | genre: tags, 25 | banner: video.thumb, 26 | poster: video.thumb, 27 | posterShape: 'landscape', 28 | year: video.publish_date && video.publish_date.split('-')[0], 29 | website: video.url, 30 | description: video.url, 31 | runtime: video.duration, 32 | popularity: Number(video.views), 33 | isFree: 1, 34 | }) 35 | } 36 | 37 | _normalizeStream(stream) { 38 | let title = 39 | (stream.title && stream.title.trim()) || 40 | (stream.quality && stream.quality.trim()) || 41 | 'SD' 42 | 43 | return super._normalizeStream({ 44 | ...stream, 45 | title, 46 | availability: 1, 47 | isFree: 1, 48 | }) 49 | } 50 | 51 | _makeMethodUrl() { 52 | throw new Error('Not implemented') 53 | } 54 | 55 | _makeEmbedUrl() { 56 | throw new Error('Not implemented') 57 | } 58 | 59 | _extractStreamsFromEmbed() { 60 | throw new Error('Not implemented') 61 | } 62 | 63 | async _requestApi(method, params) { 64 | let options = { 65 | json: true, 66 | } 67 | let url = this._makeMethodUrl(method) 68 | 69 | if (params) { 70 | url = new URL(url) 71 | Object.keys(params).forEach((name) => { 72 | if (params[name] !== undefined) { 73 | url.searchParams.set(name, params[name]) 74 | } 75 | }) 76 | } 77 | 78 | let { body } = await this.httpClient.request(url, options) 79 | 80 | // Ignore "No Videos found!"" and "No video with this ID." errors 81 | // eslint-disable-next-line eqeqeq 82 | if (body.code && body.code != 2001 && body.code != 2002) { 83 | let err = new Error(body.message) 84 | err.code = Number(body.code) 85 | throw err 86 | } 87 | 88 | return body 89 | } 90 | 91 | async _findByPage(query, page) { 92 | let { ITEMS_PER_PAGE } = this.constructor 93 | let newQuery = { 94 | 'tags[]': query.genre, 95 | search: query.search, 96 | period: 'weekly', 97 | ordering: 'mostviewed', 98 | thumbsize: 'medium', 99 | page, 100 | } 101 | 102 | let result = await this._requestApi('searchVideos', newQuery) 103 | let videos = result.videos || result.video || [] 104 | 105 | // We retry with the monthly period in case there are too few weekly videos 106 | if (!query.search && page === 1 && videos.length < ITEMS_PER_PAGE) { 107 | newQuery.period = 'monthly' 108 | let result = await this._requestApi('searchVideos', newQuery) 109 | let monthlyVideos = result.videos || result.video || [] 110 | videos = videos.concat(monthlyVideos).slice(0, ITEMS_PER_PAGE) 111 | } 112 | 113 | return videos 114 | } 115 | 116 | async _getItem(type, id) { 117 | let query = { 118 | [this.constructor.VIDEO_ID_PARAMETER]: id, 119 | } 120 | 121 | return this._requestApi('getVideoById', query) 122 | } 123 | 124 | async _getStreams(type, id) { 125 | let url = this._makeEmbedUrl(id) 126 | let { body } = await this.httpClient.request(url) 127 | 128 | let streams = this._extractStreamsFromEmbed(body) 129 | return streams && streams.map((stream) => { 130 | stream.id = id 131 | return stream 132 | }) 133 | } 134 | } 135 | 136 | 137 | export default HubTrafficAdapter 138 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { get } from 'http' 4 | import { Client as AddonClient } from 'stremio-addons' 5 | 6 | 7 | jest.mock('../src/PornClient') 8 | 9 | // Prevent the addon from printing 10 | // eslint-disable-next-line no-unused-vars 11 | let log = console.log 12 | console.log = () => {} 13 | console.error = () => {} 14 | 15 | function reset() { 16 | jest.resetModules() 17 | 18 | delete process.env.STREMIO_PORN_ID 19 | delete process.env.STREMIO_PORN_ENDPOINT 20 | delete process.env.STREMIO_PORN_PORT 21 | delete process.env.STREMIO_PORN_PROXY 22 | delete process.env.STREMIO_PORN_CACHE 23 | delete process.env.STREMIO_PORN_EMAIL 24 | delete process.env.NODE_ENV 25 | } 26 | 27 | function initAddon() { 28 | return { 29 | start() { 30 | // eslint-disable-next-line global-require 31 | this.server = require('../src/index').default 32 | 33 | // In case an error occurs before the server starts (e.g. port is in use), 34 | // it silently fails and the tests stall 35 | return new Promise((resolve, reject) => { 36 | this.server.once('listening', () => resolve(this)) 37 | this.server.once('error', (err) => { 38 | reject(err) 39 | this.stop() 40 | }) 41 | }) 42 | }, 43 | 44 | stop() { 45 | if (!this.server) { 46 | return Promise.resolve(this) 47 | } 48 | 49 | let stopPromise = new Promise((resolve) => { 50 | this.server.once('close', () => resolve(this)) 51 | }) 52 | this.server.close() 53 | return stopPromise 54 | }, 55 | } 56 | } 57 | 58 | describe('Addon @integration', () => { 59 | let addonClient 60 | let addon 61 | 62 | beforeAll(() => { 63 | addonClient = new AddonClient() 64 | addonClient.add('http://localhost') 65 | }) 66 | 67 | beforeEach(() => { 68 | reset() 69 | addon = initAddon() 70 | }) 71 | 72 | afterEach(() => { 73 | return addon.stop() 74 | }) 75 | 76 | test('When a port is not specified, starts a web server on port 80', async () => { 77 | await addon.start() 78 | expect(addon.server.address().port).toBe(80) 79 | }) 80 | 81 | test('When a port is specified, starts a web server on it', async () => { 82 | process.env.STREMIO_PORN_PORT = '9028' 83 | await addon.start() 84 | expect(addon.server.address().port).toBe(9028) 85 | }) 86 | 87 | test('meta.get is implemented', async (done) => { 88 | await addon.start() 89 | 90 | addonClient.meta.get({}, (err) => { 91 | err ? done.fail(err) : done() 92 | }) 93 | }) 94 | 95 | test('meta.find is implemented', async (done) => { 96 | await addon.start() 97 | 98 | addonClient.meta.find({}, (err) => { 99 | err ? done.fail(err) : done() 100 | }) 101 | }) 102 | 103 | test('meta.search is implemented', async (done) => { 104 | await addon.start() 105 | 106 | addonClient.meta.search({}, (err) => { 107 | err ? done.fail(err) : done() 108 | }) 109 | }) 110 | 111 | test('stream.find is implemented', async (done) => { 112 | await addon.start() 113 | 114 | addonClient.stream.find({}, (err) => { 115 | err ? done.fail(err) : done() 116 | }) 117 | }) 118 | 119 | test('The main page is accessible', async () => { 120 | await addon.start() 121 | let res = await new Promise((resolve) => { 122 | get('http://localhost', resolve) 123 | }) 124 | expect(res.statusCode).toBe(200) 125 | }) 126 | 127 | test('The static files are accessible', async () => { 128 | await addon.start() 129 | let staticFiles = [ 130 | 'logo.png', 131 | 'screenshot_discover.jpg', 132 | 'bg.jpg', 133 | ] 134 | let promises = staticFiles.map((file) => { 135 | return new Promise((resolve) => { 136 | get(`http://localhost/${file}`, resolve) 137 | }) 138 | }) 139 | let responses = await Promise.all(promises) 140 | 141 | responses.forEach((res) => { 142 | // Requests to non-existent files return the landing page, 143 | // so we check that the response is not HTML 144 | let contentType = res.headers['content-type'].split(';')[0] 145 | expect(contentType).not.toBe('text/html') 146 | expect(res.statusCode).toBe(200) 147 | }) 148 | }) 149 | }) 150 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import Stremio from 'stremio-addons' 3 | import serveStatic from 'serve-static' 4 | import chalk from 'chalk' 5 | import pkg from '../package.json' 6 | import PornClient from './PornClient' 7 | 8 | 9 | const SUPPORTED_METHODS = [ 10 | 'stream.find', 'meta.find', 'meta.search', 'meta.get', 11 | ] 12 | const STATIC_DIR = 'static' 13 | const DEFAULT_ID = 'stremio_porn' 14 | 15 | const ID = process.env.STREMIO_PORN_ID || DEFAULT_ID 16 | const ENDPOINT = process.env.STREMIO_PORN_ENDPOINT || 'http://localhost' 17 | const PORT = process.env.STREMIO_PORN_PORT || process.env.PORT || '80' 18 | const PROXY = process.env.STREMIO_PORN_PROXY || process.env.HTTPS_PROXY 19 | const CACHE = process.env.STREMIO_PORN_CACHE || process.env.REDIS_URL || '1' 20 | const EMAIL = process.env.STREMIO_PORN_EMAIL || process.env.EMAIL 21 | const IS_PROD = process.env.NODE_ENV === 'production' 22 | 23 | 24 | if (IS_PROD && ID === DEFAULT_ID) { 25 | // eslint-disable-next-line no-console 26 | console.error( 27 | chalk.red( 28 | '\nWhen running in production, a non-default addon identifier must be specified\n' 29 | ) 30 | ) 31 | process.exit(1) 32 | } 33 | 34 | let availableSites = PornClient.ADAPTERS.map((a) => a.DISPLAY_NAME).join(', ') 35 | 36 | const MANIFEST = { 37 | name: 'Porn', 38 | id: ID, 39 | version: pkg.version, 40 | description: `\ 41 | Time to unsheathe your sword! \ 42 | Watch porn videos and webcam streams from ${availableSites}\ 43 | `, 44 | types: ['movie', 'tv'], 45 | idProperty: PornClient.ID, 46 | dontAnnounce: !IS_PROD, 47 | sorts: PornClient.SORTS, 48 | // The docs mention `contactEmail`, but the template uses `email` 49 | email: EMAIL, 50 | contactEmail: EMAIL, 51 | endpoint: `${ENDPOINT}/stremioget/stremio/v1`, 52 | logo: `${ENDPOINT}/logo.png`, 53 | icon: `${ENDPOINT}/logo.png`, 54 | background: `${ENDPOINT}/bg.jpg`, 55 | // OBSOLETE: used in pre-4.0 stremio instead of idProperty/types 56 | filter: { 57 | [`query.${PornClient.ID}`]: { $exists: true }, 58 | 'query.type': { $in: ['movie', 'tv'] }, 59 | }, 60 | } 61 | 62 | 63 | function makeMethod(client, methodName) { 64 | return async (request, cb) => { 65 | let response 66 | let error 67 | 68 | try { 69 | response = await client.invokeMethod(methodName, request) 70 | } catch (err) { 71 | error = err 72 | 73 | /* eslint-disable no-console */ 74 | console.error( 75 | // eslint-disable-next-line prefer-template 76 | chalk.gray(new Date().toLocaleString()) + 77 | ' An error has occurred while processing ' + 78 | `the following request to ${methodName}:` 79 | ) 80 | console.error(request) 81 | console.error(err) 82 | /* eslint-enable no-console */ 83 | } 84 | 85 | cb(error, response) 86 | } 87 | } 88 | 89 | function makeMethods(client, methodNames) { 90 | return methodNames.reduce((methods, methodName) => { 91 | methods[methodName] = makeMethod(client, methodName) 92 | return methods 93 | }, {}) 94 | } 95 | 96 | 97 | let client = new PornClient({ proxy: PROXY, cache: CACHE }) 98 | let methods = makeMethods(client, SUPPORTED_METHODS) 99 | let addon = new Stremio.Server(methods, MANIFEST) 100 | let server = http.createServer((req, res) => { 101 | serveStatic(STATIC_DIR)(req, res, () => { 102 | addon.middleware(req, res, () => res.end()) 103 | }) 104 | }) 105 | 106 | server 107 | .on('listening', () => { 108 | let values = { 109 | endpoint: chalk.green(MANIFEST.endpoint), 110 | id: ID === DEFAULT_ID ? chalk.red(ID) : chalk.green(ID), 111 | email: EMAIL ? chalk.green(EMAIL) : chalk.red('undefined'), 112 | env: IS_PROD ? chalk.green('production') : chalk.green('development'), 113 | proxy: PROXY ? chalk.green(PROXY) : chalk.red('off'), 114 | cache: (CACHE === '0') ? 115 | chalk.red('off') : 116 | chalk.green(CACHE === '1' ? 'on' : CACHE), 117 | } 118 | 119 | // eslint-disable-next-line no-console 120 | console.log(` 121 | ${MANIFEST.name} Addon is listening on port ${PORT} 122 | 123 | Endpoint: ${values.endpoint} 124 | Addon Id: ${values.id} 125 | Email: ${values.email} 126 | Environment: ${values.env} 127 | Proxy: ${values.proxy} 128 | Cache: ${values.cache} 129 | `) 130 | }) 131 | .listen(PORT) 132 | 133 | 134 | export default server 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

Porn Addon for Stremio

5 |

6 | Time to unsheathe your sword! 7 |

8 | 9 | This is a [Stremio](https://www.stremio.com/) addon that provides porn content from various websites: 10 | 11 | - __Videos__ _(Movies)_: PornHub, RedTube, YouPorn, SpankWire and Porn.com 12 | - __Webcam streams__ _(TV Channels)_: Chaturbate 13 | 14 | 15 | ## Features 16 | 17 | - Adds a dedicated tab in Discover for each website 18 | - Works in Stremio v4 and v3.6 19 | - Supports Docker out of the box 20 | - Caches results in memory or Redis 21 | - Limits the number of concurrent requests to avoid overloading the sites 22 | - Supports HTTPS proxy 23 | - Configurable via environment variables 24 | - Prints a nicely formatted status message when run 25 | - The logo is dope 🗡💖 26 | 27 | 28 | ## Running 29 | 30 | The addon is a web server that fetches video streams from the porn sites in response to requests from Stremio clients. It uses environment variables for configuration and includes a handful of npm scripts to run with or without Docker. 31 | 32 | To install and quickly start the addon, do: 33 | 34 | ```bash 35 | git clone https://github.com/naughty-doge/stremio-porn 36 | cd stremio-porn 37 | yarn # or `npm install` 38 | yarn start # or `npm start` 39 | ``` 40 | 41 | By default the server starts on `localhost:80` in development mode and doesn't announce itself to the Stremio addon tracker. To add the addon to Stremio, open its endpoint in the browser and click the Install button, or enter the URL in the app's Addons section. 42 | 43 | In order for the addon to work publicly, the following environment variables must be set: 44 | - `NODE_ENV` to `production` 45 | - `STREMIO_PORN_ENDPOINT` to a public URL of the server 46 | - `STREMIO_PORN_ID` to a non-default value 47 | 48 | Note: since this addon scrapes pages, it is recommended to run it behind a proxy and use Redis caching. 49 | 50 | 51 | ## Development 52 | 53 | The code is written in ES7 and then transpiled with Babel. It is covered by a suite of Jest tests, and the staged files are automatically linted with ESLint. The transpiled files are included in the repository: this makes for quicker start and eases deployment to different environments such as Docker and Heroku. 54 | 55 | 56 | ## npm scripts 57 | 58 | Each of these scripts can be used with `yarn
10 | -------------------------------------------------------------------------------- /dist/adapters/PornCom.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _BaseAdapter = _interopRequireDefault(require("./BaseAdapter")); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } function _next(value) { step("next", value); } function _throw(err) { step("throw", err); } _next(); }); }; } 13 | 14 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 15 | 16 | const BASE_URL = 'https://www.porn.com'; 17 | const API_URL = 'https://api.porn.com'; 18 | const VIDEOS_API_URL = `${API_URL}/videos/find.json`; 19 | const ITEMS_PER_PAGE = 70; 20 | const SUPPORTED_TYPES = ['movie']; 21 | 22 | function formatDuration(seconds) { 23 | seconds = Number(seconds); 24 | let minutesString = Math.floor(seconds / 60); 25 | let secondsString = `0${seconds % 60}`.slice(-2); 26 | return `${minutesString}:${secondsString}`; 27 | } 28 | 29 | class PornCom extends _BaseAdapter.default { 30 | _normalizeItem(item) { 31 | return super._normalizeItem({ 32 | type: 'movie', 33 | id: item.id, 34 | name: item.title, 35 | genre: item.tags, 36 | banner: item.thumb, 37 | poster: item.thumb, 38 | posterShape: 'landscape', 39 | website: item.url, 40 | description: item.url, 41 | runtime: item.duration ? formatDuration(item.duration) : undefined, 42 | year: new Date(item.active_date).getFullYear(), 43 | popularity: item.views && Number(item.views), 44 | isFree: 1 45 | }); 46 | } 47 | 48 | _normalizeStream(stream) { 49 | return super._normalizeStream({ 50 | id: stream.id, 51 | url: stream.url, 52 | title: `${stream.quality}p`, 53 | availability: 1, 54 | live: true, 55 | isFree: true 56 | }); 57 | } 58 | 59 | _makeEmbedUrl(id) { 60 | return `${BASE_URL}/videos/embed/${id}`; 61 | } 62 | 63 | _makeDownloadUrl(id, quality) { 64 | return `${BASE_URL}/download/${quality}/${id}.mp4`; 65 | } 66 | 67 | _parseApiResponse(response) { 68 | if (typeof response === 'string') { 69 | response = JSON.parse(response); 70 | } 71 | 72 | if (!response.success) { 73 | throw new Error(response.message); 74 | } 75 | 76 | return response.result; 77 | } 78 | 79 | _extractQualitiesFromEmbedPage(body) { 80 | return body.match(/['"]?id['"]?:\s*['"]\d+p['"]/gi) // Find id:"240p" 81 | .map(item => item.match(/\d+/)[0]) // Extract 240 82 | .filter(quality => Number(quality) < 360); // 360+ are restricted 83 | } 84 | 85 | _getQualities(id) { 86 | var _this = this; 87 | 88 | return _asyncToGenerator(function* () { 89 | let embedUrl = _this._makeEmbedUrl(id); 90 | 91 | let { 92 | body 93 | } = yield _this.httpClient.request(embedUrl); 94 | return _this._extractQualitiesFromEmbedPage(body); 95 | })(); 96 | } 97 | 98 | _findByPage(query, page) { 99 | var _this2 = this; 100 | 101 | return _asyncToGenerator(function* () { 102 | let options = { 103 | json: true, 104 | query: { 105 | page, 106 | limit: ITEMS_PER_PAGE, 107 | search: query.search, 108 | cats: query.genre 109 | } 110 | }; 111 | let { 112 | body 113 | } = yield _this2.httpClient.request(VIDEOS_API_URL, options); 114 | return _this2._parseApiResponse(body); 115 | })(); 116 | } 117 | 118 | _getItem(type, id) { 119 | var _this3 = this; 120 | 121 | return _asyncToGenerator(function* () { 122 | let options = { 123 | json: true, 124 | query: { 125 | id, 126 | limit: 1 127 | } 128 | }; 129 | let { 130 | body 131 | } = yield _this3.httpClient.request(VIDEOS_API_URL, options); 132 | return _this3._parseApiResponse(body)[0]; 133 | })(); 134 | } 135 | 136 | _getStreams(type, id) { 137 | var _this4 = this; 138 | 139 | return _asyncToGenerator(function* () { 140 | let qualities = yield _this4._getQualities(id); 141 | return qualities.map(quality => { 142 | let url = _this4._makeDownloadUrl(id, quality); 143 | 144 | return { 145 | id, 146 | url, 147 | quality 148 | }; 149 | }); 150 | })(); 151 | } 152 | 153 | } 154 | 155 | _defineProperty(_defineProperty(_defineProperty(PornCom, "DISPLAY_NAME", 'Porn.com'), "SUPPORTED_TYPES", SUPPORTED_TYPES), "ITEMS_PER_PAGE", ITEMS_PER_PAGE); 156 | 157 | var _default = PornCom; 158 | exports.default = _default; 159 | //# sourceMappingURL=PornCom.js.map -------------------------------------------------------------------------------- /src/adapters/EPorner.js: -------------------------------------------------------------------------------- 1 | import { xml2js } from 'xml-js' 2 | import cheerio from 'cheerio' 3 | import BaseAdapter from './BaseAdapter' 4 | 5 | 6 | const BASE_URL = 'https://www.eporner.com' 7 | const ITEMS_PER_PAGE = 60 8 | const SUPPORTED_TYPES = ['movie'] 9 | 10 | 11 | class EPorner extends BaseAdapter { 12 | static DISPLAY_NAME = 'EPorner' 13 | static SUPPORTED_TYPES = SUPPORTED_TYPES 14 | static ITEMS_PER_PAGE = ITEMS_PER_PAGE 15 | 16 | _normalizePageItem(item) { 17 | let id = item.url.split('/')[4] 18 | let duration = item.duration && item.duration 19 | .replace('M', ':') 20 | .replace(/[TS]/gi, '') 21 | 22 | return { 23 | type: 'movie', 24 | id: id, 25 | name: item.title, 26 | genre: item.tags, 27 | banner: item.image, 28 | poster: item.image, 29 | posterShape: 'landscape', 30 | website: item.url, 31 | description: item.url, 32 | runtime: duration, 33 | isFree: 1, 34 | } 35 | } 36 | 37 | _normalizeApiItem(item) { 38 | let tags = item.keywords && item.keywords._text 39 | .split(',') 40 | .slice(1) 41 | .map((keyword) => keyword.trim()) 42 | .filter((keyword) => keyword.split(' ').length < 3) 43 | 44 | return { 45 | type: 'movie', 46 | id: item.sid ? item.sid._text : item.id._text, 47 | name: item.title._text, 48 | genre: tags, 49 | banner: item.imgthumb._text, 50 | poster: item['imgthumb320x240']._text, 51 | posterShape: 'landscape', 52 | website: item.loc._text, 53 | description: item.loc._text, 54 | runtime: item.lenghtmin._text, 55 | popularity: Number(item.views._text || 0), 56 | isFree: 1, 57 | } 58 | } 59 | 60 | _normalizeItem(item) { 61 | if (item._source === 'moviePage') { 62 | item = this._normalizePageItem(item) 63 | } else { 64 | item = this._normalizeApiItem(item) 65 | } 66 | 67 | return super._normalizeItem(item) 68 | } 69 | 70 | _normalizeStream(stream) { 71 | let quality = stream.url.match(/-(\d+)p/i) 72 | 73 | return super._normalizeStream({ 74 | id: stream.id, 75 | url: stream.url, 76 | title: quality ? quality[1] : 'Watch', 77 | availability: 1, 78 | live: true, 79 | isFree: true, 80 | }) 81 | } 82 | 83 | _makeApiUrl(query, skip, limit) { 84 | let { search, genre } = query 85 | let keywords 86 | 87 | if (search && genre) { 88 | keywords = `${genre},${search}` 89 | } else { 90 | keywords = search || genre || 'all' 91 | } 92 | 93 | keywords = keywords.replace(' ', '+') 94 | return `${BASE_URL}/api_xml/${keywords}/${limit}/${skip}/adddate` 95 | } 96 | 97 | _makeMovieUrl(id) { 98 | return `${BASE_URL}/hd-porn/${id}` 99 | } 100 | 101 | _makeVideoDownloadUrl(path) { 102 | return BASE_URL + path 103 | } 104 | 105 | _parseApiResponse(xml) { 106 | let results = xml2js(xml, { 107 | compact: true, 108 | trim: true, 109 | })['eporner-data'].movie 110 | 111 | if (!results) { 112 | return [] 113 | } else if (!Array.isArray(results)) { 114 | return [results] 115 | } else { 116 | return results 117 | } 118 | } 119 | 120 | _parseMoviePage(body) { 121 | let $ = cheerio.load(body) 122 | let title = $('meta[property="og:title"]') 123 | .attr('content') 124 | .replace(/(\s*-\s*)?EPORNER/i, '') 125 | let description = $('meta[property="og:description"]').attr('content') 126 | let duration = description.match(/duration:\s*((:?\d)+)/i)[1] 127 | let url = $('meta[property="og:url"]').attr('content') 128 | let image = $('meta[property="og:image"]').attr('content') 129 | let tags = $('#hd-porn-tags td') 130 | .filter((i, item) => $(item).text().trim() === 'Tags:') 131 | .next() 132 | .find('a') 133 | .map((i, item) => $(item).text().trim()) 134 | .toArray() 135 | let downloadUrls = $('#hd-porn-dload a') 136 | .map((i, link) => { 137 | let href = $(link).attr('href') 138 | return this._makeVideoDownloadUrl(href) 139 | }) 140 | .toArray() 141 | 142 | return { 143 | _source: 'moviePage', 144 | title, url, image, tags, duration, downloadUrls, 145 | } 146 | } 147 | 148 | async _find(query, { skip, limit }) { 149 | let url = this._makeApiUrl(query, skip, limit) 150 | let { body } = await this.httpClient.request(url) 151 | return this._parseApiResponse(body) 152 | } 153 | 154 | async _getItem(type, id) { 155 | let url = this._makeMovieUrl(id) 156 | let { body } = await this.httpClient.request(url) 157 | return this._parseMoviePage(body) 158 | } 159 | 160 | async _getStreams(type, id) { 161 | // Video downloads are restricted to 30 per day per guest 162 | 163 | let url = this._makeMovieUrl(id) 164 | let { body } = await this.httpClient.request(url) 165 | let { downloadUrls } = this._parseMoviePage(body) 166 | 167 | let streamUrls = downloadUrls.map((url) => { 168 | return this.httpClient.request(url, { followRedirect: false }) 169 | }) 170 | streamUrls = await Promise.all(streamUrls) 171 | 172 | return streamUrls 173 | .map((res) => { 174 | return { id, url: res.headers.location } 175 | }) 176 | .filter((stream) => stream.url) 177 | } 178 | } 179 | 180 | 181 | export default EPorner 182 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _http = _interopRequireDefault(require("http")); 9 | 10 | var _stremioAddons = _interopRequireDefault(require("stremio-addons")); 11 | 12 | var _serveStatic = _interopRequireDefault(require("serve-static")); 13 | 14 | var _chalk = _interopRequireDefault(require("chalk")); 15 | 16 | var _package = _interopRequireDefault(require("../package.json")); 17 | 18 | var _PornClient = _interopRequireDefault(require("./PornClient")); 19 | 20 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 21 | 22 | function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } function _next(value) { step("next", value); } function _throw(err) { step("throw", err); } _next(); }); }; } 23 | 24 | const SUPPORTED_METHODS = ['stream.find', 'meta.find', 'meta.search', 'meta.get']; 25 | const STATIC_DIR = 'static'; 26 | const DEFAULT_ID = 'stremio_porn'; 27 | const ID = process.env.STREMIO_PORN_ID || DEFAULT_ID; 28 | const ENDPOINT = process.env.STREMIO_PORN_ENDPOINT || 'http://localhost'; 29 | const PORT = process.env.STREMIO_PORN_PORT || process.env.PORT || '80'; 30 | const PROXY = process.env.STREMIO_PORN_PROXY || process.env.HTTPS_PROXY; 31 | const CACHE = process.env.STREMIO_PORN_CACHE || process.env.REDIS_URL || '1'; 32 | const EMAIL = process.env.STREMIO_PORN_EMAIL || process.env.EMAIL; 33 | const IS_PROD = process.env.NODE_ENV === 'production'; 34 | 35 | if (IS_PROD && ID === DEFAULT_ID) { 36 | // eslint-disable-next-line no-console 37 | console.error(_chalk.default.red('\nWhen running in production, a non-default addon identifier must be specified\n')); 38 | process.exit(1); 39 | } 40 | 41 | let availableSites = _PornClient.default.ADAPTERS.map(a => a.DISPLAY_NAME).join(', '); 42 | 43 | const MANIFEST = { 44 | name: 'Porn', 45 | id: ID, 46 | version: _package.default.version, 47 | description: `\ 48 | Time to unsheathe your sword! \ 49 | Watch porn videos and webcam streams from ${availableSites}\ 50 | `, 51 | types: ['movie', 'tv'], 52 | idProperty: _PornClient.default.ID, 53 | dontAnnounce: !IS_PROD, 54 | sorts: _PornClient.default.SORTS, 55 | // The docs mention `contactEmail`, but the template uses `email` 56 | email: EMAIL, 57 | contactEmail: EMAIL, 58 | endpoint: `${ENDPOINT}/stremioget/stremio/v1`, 59 | logo: `${ENDPOINT}/logo.png`, 60 | icon: `${ENDPOINT}/logo.png`, 61 | background: `${ENDPOINT}/bg.jpg`, 62 | // OBSOLETE: used in pre-4.0 stremio instead of idProperty/types 63 | filter: { 64 | [`query.${_PornClient.default.ID}`]: { 65 | $exists: true 66 | }, 67 | 'query.type': { 68 | $in: ['movie', 'tv'] 69 | } 70 | } 71 | }; 72 | 73 | function makeMethod(client, methodName) { 74 | return ( 75 | /*#__PURE__*/ 76 | function () { 77 | var _ref = _asyncToGenerator(function* (request, cb) { 78 | let response; 79 | let error; 80 | 81 | try { 82 | response = yield client.invokeMethod(methodName, request); 83 | } catch (err) { 84 | error = err; 85 | /* eslint-disable no-console */ 86 | 87 | console.error( // eslint-disable-next-line prefer-template 88 | _chalk.default.gray(new Date().toLocaleString()) + ' An error has occurred while processing ' + `the following request to ${methodName}:`); 89 | console.error(request); 90 | console.error(err); 91 | /* eslint-enable no-console */ 92 | } 93 | 94 | cb(error, response); 95 | }); 96 | 97 | return function (_x, _x2) { 98 | return _ref.apply(this, arguments); 99 | }; 100 | }() 101 | ); 102 | } 103 | 104 | function makeMethods(client, methodNames) { 105 | return methodNames.reduce((methods, methodName) => { 106 | methods[methodName] = makeMethod(client, methodName); 107 | return methods; 108 | }, {}); 109 | } 110 | 111 | let client = new _PornClient.default({ 112 | proxy: PROXY, 113 | cache: CACHE 114 | }); 115 | let methods = makeMethods(client, SUPPORTED_METHODS); 116 | let addon = new _stremioAddons.default.Server(methods, MANIFEST); 117 | 118 | let server = _http.default.createServer((req, res) => { 119 | (0, _serveStatic.default)(STATIC_DIR)(req, res, () => { 120 | addon.middleware(req, res, () => res.end()); 121 | }); 122 | }); 123 | 124 | server.on('listening', () => { 125 | let values = { 126 | endpoint: _chalk.default.green(MANIFEST.endpoint), 127 | id: ID === DEFAULT_ID ? _chalk.default.red(ID) : _chalk.default.green(ID), 128 | email: EMAIL ? _chalk.default.green(EMAIL) : _chalk.default.red('undefined'), 129 | env: IS_PROD ? _chalk.default.green('production') : _chalk.default.green('development'), 130 | proxy: PROXY ? _chalk.default.green(PROXY) : _chalk.default.red('off'), 131 | cache: CACHE === '0' ? _chalk.default.red('off') : _chalk.default.green(CACHE === '1' ? 'on' : CACHE) // eslint-disable-next-line no-console 132 | 133 | }; 134 | console.log(` 135 | ${MANIFEST.name} Addon is listening on port ${PORT} 136 | 137 | Endpoint: ${values.endpoint} 138 | Addon Id: ${values.id} 139 | Email: ${values.email} 140 | Environment: ${values.env} 141 | Proxy: ${values.proxy} 142 | Cache: ${values.cache} 143 | `); 144 | }).listen(PORT); 145 | var _default = server; 146 | exports.default = _default; 147 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /dist/adapters/Chaturbate.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _cheerio = _interopRequireDefault(require("cheerio")); 9 | 10 | var _BaseAdapter = _interopRequireDefault(require("./BaseAdapter")); 11 | 12 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 13 | 14 | function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } function _next(value) { step("next", value); } function _throw(err) { step("throw", err); } _next(); }); }; } 15 | 16 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === 'function') { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function (key) { _defineProperty(target, key, source[key]); }); } return target; } 17 | 18 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 19 | 20 | const BASE_URL = 'https://chaturbate.com'; 21 | const GET_STREAM_URL = 'https://chaturbate.com/get_edge_hls_url_ajax/'; // Chaturbate's number of items per page varies from load to load, 22 | // so this is the minimum number 23 | 24 | const ITEMS_PER_PAGE = 60; 25 | const SUPPORTED_TYPES = ['tv']; 26 | 27 | class Chaturbate extends _BaseAdapter.default { 28 | _normalizeItem(item) { 29 | return super._normalizeItem({ 30 | type: 'tv', 31 | id: item.id, 32 | name: item.id, 33 | genre: item.tags, 34 | banner: item.poster, 35 | poster: item.poster, 36 | posterShape: 'landscape', 37 | website: item.url, 38 | description: item.subject, 39 | popularity: item.viewers, 40 | isFree: true 41 | }); 42 | } 43 | 44 | _normalizeStream(stream) { 45 | return super._normalizeStream(_objectSpread({}, stream, { 46 | title: 'Watch', 47 | availability: 1, 48 | live: true, 49 | isFree: true 50 | })); 51 | } 52 | 53 | _parseListPage(body) { 54 | let $ = _cheerio.default.load(body); 55 | 56 | let tagRegexp = /#\S+/g; 57 | return $('.list > li').map((i, item) => { 58 | let $item = $(item); 59 | let $link = $item.find('.title > a'); 60 | let id = $link.text().trim(); 61 | let url = BASE_URL + $link.attr('href'); 62 | let subject = $item.find('.subject').text().trim(); 63 | let tags = (subject.match(tagRegexp) || []).map(tag => tag.slice(1)); 64 | let poster = $item.find('img').attr('src'); 65 | let viewers = $item.find('.cams').text().match(/(\d+) viewers/i); 66 | viewers = viewers && Number(viewers[1]); 67 | return { 68 | id, 69 | url, 70 | subject, 71 | poster, 72 | tags, 73 | viewers 74 | }; 75 | }).toArray(); 76 | } 77 | 78 | _parseItemPage(body) { 79 | let $ = _cheerio.default.load(body); 80 | 81 | let tagRegexp = /#\S+/g; 82 | let url = $('meta[property="og:url"]').attr('content'); 83 | let id = url.split('/').slice(-2, -1)[0]; 84 | let subject = $('meta[property="og:description"]').attr('content').trim(); 85 | let tags = (subject.match(tagRegexp) || []).map(tag => tag.slice(1)); 86 | let poster = $('meta[property="og:image"]').attr('content'); 87 | return { 88 | id, 89 | url, 90 | subject, 91 | poster, 92 | tags 93 | }; 94 | } 95 | 96 | _findByPage(query, page) { 97 | var _this = this; 98 | 99 | return _asyncToGenerator(function* () { 100 | let options = { 101 | query: { 102 | page, 103 | keywords: query.search 104 | } 105 | }; 106 | let url = query.genre ? `${BASE_URL}/tag/${query.genre}` : BASE_URL; 107 | let { 108 | body 109 | } = yield _this.httpClient.request(url, options); 110 | return _this._parseListPage(body); 111 | })(); 112 | } 113 | 114 | _getItem(type, id) { 115 | var _this2 = this; 116 | 117 | return _asyncToGenerator(function* () { 118 | let url = `${BASE_URL}/${id}`; 119 | let { 120 | body 121 | } = yield _this2.httpClient.request(url); 122 | return _this2._parseItemPage(body); 123 | })(); 124 | } 125 | 126 | _getStreams(type, id) { 127 | var _this3 = this; 128 | 129 | return _asyncToGenerator(function* () { 130 | let options = { 131 | form: true, 132 | json: true, 133 | method: 'post', 134 | headers: { 135 | 'Content-Type': 'application/x-www-form-urlencoded', 136 | 'X-Requested-With': 'XMLHttpRequest', 137 | Referer: `${BASE_URL}/${id}` 138 | }, 139 | body: { 140 | /* eslint-disable-next-line camelcase */ 141 | room_slug: id, 142 | bandwidth: 'high' 143 | } 144 | }; 145 | let { 146 | body 147 | } = yield _this3.httpClient.request(GET_STREAM_URL, options); 148 | 149 | if (body.success && body.room_status === 'public') { 150 | return [{ 151 | id, 152 | url: body.url 153 | }]; 154 | } else { 155 | return []; 156 | } 157 | })(); 158 | } 159 | 160 | } 161 | 162 | _defineProperty(_defineProperty(_defineProperty(Chaturbate, "DISPLAY_NAME", 'Chaturbate'), "SUPPORTED_TYPES", SUPPORTED_TYPES), "ITEMS_PER_PAGE", ITEMS_PER_PAGE); 163 | 164 | var _default = Chaturbate; 165 | exports.default = _default; 166 | //# sourceMappingURL=Chaturbate.js.map -------------------------------------------------------------------------------- /dist/adapters/HubTrafficAdapter.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _url = require("url"); 9 | 10 | var _BaseAdapter = _interopRequireDefault(require("./BaseAdapter")); 11 | 12 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 13 | 14 | function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } function _next(value) { step("next", value); } function _throw(err) { step("throw", err); } _next(); }); }; } 15 | 16 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === 'function') { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function (key) { _defineProperty(target, key, source[key]); }); } return target; } 17 | 18 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 19 | 20 | // https://www.hubtraffic.com/ 21 | class HubTrafficAdapter extends _BaseAdapter.default { 22 | _normalizeItem(item) { 23 | let video = item.video || item; 24 | let { 25 | TAGS_TO_SKIP 26 | } = this.constructor; 27 | let tags = video.tags && Object.values(video.tags).map(tag => { 28 | return typeof tag === 'string' ? tag : tag.tag_name; 29 | }).filter(tag => !TAGS_TO_SKIP.includes(tag.toLowerCase())); 30 | return super._normalizeItem({ 31 | type: 'movie', 32 | id: video.video_id || video.id, 33 | name: video.title.trim(), 34 | genre: tags, 35 | banner: video.thumb, 36 | poster: video.thumb, 37 | posterShape: 'landscape', 38 | year: video.publish_date && video.publish_date.split('-')[0], 39 | website: video.url, 40 | description: video.url, 41 | runtime: video.duration, 42 | popularity: Number(video.views), 43 | isFree: 1 44 | }); 45 | } 46 | 47 | _normalizeStream(stream) { 48 | let title = stream.title && stream.title.trim() || stream.quality && stream.quality.trim() || 'SD'; 49 | return super._normalizeStream(_objectSpread({}, stream, { 50 | title, 51 | availability: 1, 52 | isFree: 1 53 | })); 54 | } 55 | 56 | _makeMethodUrl() { 57 | throw new Error('Not implemented'); 58 | } 59 | 60 | _makeEmbedUrl() { 61 | throw new Error('Not implemented'); 62 | } 63 | 64 | _extractStreamsFromEmbed() { 65 | throw new Error('Not implemented'); 66 | } 67 | 68 | _requestApi(method, params) { 69 | var _this = this; 70 | 71 | return _asyncToGenerator(function* () { 72 | let options = { 73 | json: true 74 | }; 75 | 76 | let url = _this._makeMethodUrl(method); 77 | 78 | if (params) { 79 | url = new _url.URL(url); 80 | Object.keys(params).forEach(name => { 81 | if (params[name] !== undefined) { 82 | url.searchParams.set(name, params[name]); 83 | } 84 | }); 85 | } 86 | 87 | let { 88 | body 89 | } = yield _this.httpClient.request(url, options); // Ignore "No Videos found!"" and "No video with this ID." errors 90 | // eslint-disable-next-line eqeqeq 91 | 92 | if (body.code && body.code != 2001 && body.code != 2002) { 93 | let err = new Error(body.message); 94 | err.code = Number(body.code); 95 | throw err; 96 | } 97 | 98 | return body; 99 | })(); 100 | } 101 | 102 | _findByPage(query, page) { 103 | var _this2 = this; 104 | 105 | return _asyncToGenerator(function* () { 106 | let { 107 | ITEMS_PER_PAGE 108 | } = _this2.constructor; 109 | let newQuery = { 110 | 'tags[]': query.genre, 111 | search: query.search, 112 | period: 'weekly', 113 | ordering: 'mostviewed', 114 | thumbsize: 'medium', 115 | page 116 | }; 117 | let result = yield _this2._requestApi('searchVideos', newQuery); 118 | let videos = result.videos || result.video || []; // We retry with the monthly period in case there are too few weekly videos 119 | 120 | if (!query.search && page === 1 && videos.length < ITEMS_PER_PAGE) { 121 | newQuery.period = 'monthly'; 122 | let result = yield _this2._requestApi('searchVideos', newQuery); 123 | let monthlyVideos = result.videos || result.video || []; 124 | videos = videos.concat(monthlyVideos).slice(0, ITEMS_PER_PAGE); 125 | } 126 | 127 | return videos; 128 | })(); 129 | } 130 | 131 | _getItem(type, id) { 132 | var _this3 = this; 133 | 134 | return _asyncToGenerator(function* () { 135 | let query = { 136 | [_this3.constructor.VIDEO_ID_PARAMETER]: id 137 | }; 138 | return _this3._requestApi('getVideoById', query); 139 | })(); 140 | } 141 | 142 | _getStreams(type, id) { 143 | var _this4 = this; 144 | 145 | return _asyncToGenerator(function* () { 146 | let url = _this4._makeEmbedUrl(id); 147 | 148 | let { 149 | body 150 | } = yield _this4.httpClient.request(url); 151 | 152 | let streams = _this4._extractStreamsFromEmbed(body); 153 | 154 | return streams && streams.map(stream => { 155 | stream.id = id; 156 | return stream; 157 | }); 158 | })(); 159 | } 160 | 161 | } 162 | 163 | _defineProperty(_defineProperty(_defineProperty(HubTrafficAdapter, "SUPPORTED_TYPES", ['movie']), "TAGS_TO_SKIP", []), "VIDEO_ID_PARAMETER", 'video_id'); 164 | 165 | var _default = HubTrafficAdapter; 166 | exports.default = _default; 167 | //# sourceMappingURL=HubTrafficAdapter.js.map -------------------------------------------------------------------------------- /dist/adapters/BaseAdapter.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _bottleneck = _interopRequireDefault(require("bottleneck")); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } function _next(value) { step("next", value); } function _throw(err) { step("throw", err); } _next(); }); }; } 13 | 14 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === 'function') { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function (key) { _defineProperty(target, key, source[key]); }); } return target; } 15 | 16 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 17 | 18 | // Contains some common methods as well as public wrappers 19 | // that prepare requests, redirect them to private methods 20 | // and normalize results 21 | class BaseAdapter { 22 | constructor(httpClient) { 23 | this.httpClient = httpClient; 24 | this.scheduler = new _bottleneck.default({ 25 | maxConcurrent: this.constructor.MAX_CONCURRENT_REQUESTS 26 | }); 27 | } 28 | 29 | _normalizeItem(item) { 30 | return item; 31 | } 32 | 33 | _normalizeStream(stream) { 34 | if (stream.name) { 35 | return stream; 36 | } else { 37 | return _objectSpread({}, stream, { 38 | name: this.constructor.name 39 | }); 40 | } 41 | } 42 | 43 | _paginate(request) { 44 | let itemsPerPage = this.constructor.ITEMS_PER_PAGE || Infinity; 45 | let { 46 | skip = 0, 47 | limit = itemsPerPage 48 | } = request; 49 | limit = Math.min(limit, this.constructor.MAX_RESULTS_PER_REQUEST); 50 | itemsPerPage = Math.min(itemsPerPage, limit); 51 | let firstPage = Math.ceil((skip + 0.1) / itemsPerPage) || 1; 52 | let pageCount = Math.ceil(limit / itemsPerPage); 53 | let pages = []; 54 | 55 | for (let i = firstPage; pages.length < pageCount; i++) { 56 | pages.push(i); 57 | } 58 | 59 | return { 60 | pages, 61 | skip, 62 | limit, 63 | skipOnFirstPage: skip % itemsPerPage 64 | }; 65 | } 66 | 67 | _validateRequest(request, typeRequired) { 68 | let { 69 | SUPPORTED_TYPES 70 | } = this.constructor; 71 | 72 | if (typeof request !== 'object') { 73 | throw new Error(`A request must be an object, ${typeof request} given`); 74 | } 75 | 76 | if (!request.query) { 77 | throw new Error('Request query must not be empty'); 78 | } 79 | 80 | if (typeRequired && !request.query.type) { 81 | throw new Error('Content type must be specified'); 82 | } 83 | 84 | if (request.query.type && !SUPPORTED_TYPES.includes(request.query.type)) { 85 | throw new Error(`Content type ${request.query.type} is not supported`); 86 | } 87 | } 88 | 89 | _find(query, pagination) { 90 | var _this = this; 91 | 92 | return _asyncToGenerator(function* () { 93 | let { 94 | pages, 95 | limit, 96 | skipOnFirstPage 97 | } = pagination; 98 | let requests = pages.map(page => { 99 | return _this._findByPage(query, page); 100 | }); 101 | let results = yield Promise.all(requests); 102 | results = [].concat(...results).filter(item => item); 103 | return results.slice(skipOnFirstPage, skipOnFirstPage + limit); 104 | })(); 105 | } 106 | 107 | find(request) { 108 | var _this2 = this; 109 | 110 | return _asyncToGenerator(function* () { 111 | _this2._validateRequest(request); 112 | 113 | let pagination = _this2._paginate(request); 114 | 115 | let { 116 | query 117 | } = request; 118 | 119 | if (!query.type) { 120 | query = _objectSpread({}, query, { 121 | type: _this2.constructor.SUPPORTED_TYPES[0] 122 | }); 123 | } 124 | 125 | let results = yield _this2.scheduler.schedule(() => { 126 | return _this2._find(query, pagination); 127 | }); 128 | 129 | if (results) { 130 | return results.map(item => _this2._normalizeItem(item)); 131 | } else { 132 | return []; 133 | } 134 | })(); 135 | } 136 | 137 | getItem(request) { 138 | var _this3 = this; 139 | 140 | return _asyncToGenerator(function* () { 141 | _this3._validateRequest(request, true); 142 | 143 | let { 144 | type, 145 | id 146 | } = request.query; 147 | let result = yield _this3.scheduler.schedule(() => { 148 | return _this3._getItem(type, id); 149 | }); 150 | return result ? [_this3._normalizeItem(result)] : []; 151 | })(); 152 | } 153 | 154 | getStreams(request) { 155 | var _this4 = this; 156 | 157 | return _asyncToGenerator(function* () { 158 | _this4._validateRequest(request, true); 159 | 160 | let { 161 | type, 162 | id 163 | } = request.query; 164 | let results = yield _this4.scheduler.schedule(() => { 165 | return _this4._getStreams(type, id); 166 | }); 167 | 168 | if (results) { 169 | return results.map(stream => _this4._normalizeStream(stream)); 170 | } else { 171 | return []; 172 | } 173 | })(); 174 | } 175 | 176 | } 177 | 178 | _defineProperty(_defineProperty(_defineProperty(BaseAdapter, "SUPPORTED_TYPES", []), "MAX_RESULTS_PER_REQUEST", 100), "MAX_CONCURRENT_REQUESTS", 3); 179 | 180 | var _default = BaseAdapter; 181 | exports.default = _default; 182 | //# sourceMappingURL=BaseAdapter.js.map -------------------------------------------------------------------------------- /src/PornClient.js: -------------------------------------------------------------------------------- 1 | import cacheManager from 'cache-manager' 2 | import redisStore from 'cache-manager-redis-store' 3 | import HttpClient from './HttpClient' 4 | import PornHub from './adapters/PornHub' 5 | import RedTube from './adapters/RedTube' 6 | import YouPorn from './adapters/YouPorn' 7 | import SpankWire from './adapters/SpankWire' 8 | import PornCom from './adapters/PornCom' 9 | import Chaturbate from './adapters/Chaturbate' 10 | 11 | // EPorner has restricted video downloads to 30 per day per guest 12 | // import EPorner from './adapters/EPorner' 13 | 14 | 15 | const ID = 'porn_id' 16 | const SORT_PROP_PREFIX = 'popularities.porn.' 17 | const CACHE_PREFIX = 'stremio-porn|' 18 | // Making multiple requests to multiple adapters for different types 19 | // and then aggregating them is a lot of work, 20 | // so we only support 1 adapter per request for now. 21 | const MAX_ADAPTERS_PER_REQUEST = 1 22 | const ADAPTERS = [PornHub, RedTube, YouPorn, SpankWire, PornCom, Chaturbate] 23 | const SORTS = ADAPTERS.map(({ name, DISPLAY_NAME, SUPPORTED_TYPES }) => ({ 24 | name: `Porn: ${DISPLAY_NAME}`, 25 | prop: `${SORT_PROP_PREFIX}${name}`, 26 | types: SUPPORTED_TYPES, 27 | })) 28 | const METHODS = { 29 | 'stream.find': { 30 | adapterMethod: 'getStreams', 31 | cacheTtl: 300, 32 | idProp: ID, 33 | expectsArray: true, 34 | }, 35 | 'meta.find': { 36 | adapterMethod: 'find', 37 | cacheTtl: 300, 38 | idProp: 'id', 39 | expectsArray: true, 40 | }, 41 | 'meta.search': { 42 | adapterMethod: 'find', 43 | cacheTtl: 3600, 44 | idProp: 'id', 45 | expectsArray: true, 46 | }, 47 | 'meta.get': { 48 | adapterMethod: 'getItem', 49 | cacheTtl: 300, 50 | idProp: 'id', 51 | expectsArray: false, 52 | }, 53 | } 54 | 55 | 56 | function makePornId(adapter, type, id) { 57 | return `${ID}:${adapter}-${type}-${id}` 58 | } 59 | 60 | function parsePornId(pornId) { 61 | let [adapter, type, id] = pornId.split(':').pop().split('-') 62 | return { adapter, type, id } 63 | } 64 | 65 | function normalizeRequest(request) { 66 | let { query, sort, limit, skip } = request 67 | let adapters = [] 68 | 69 | if (sort) { 70 | adapters = Object.keys(sort) 71 | .filter((p) => p.startsWith(SORT_PROP_PREFIX)) 72 | .map((p) => p.slice(SORT_PROP_PREFIX.length)) 73 | } 74 | 75 | if (typeof query === 'string') { 76 | query = { search: query } 77 | } else if (query) { 78 | query = { ...query } 79 | } else { 80 | query = {} 81 | } 82 | 83 | if (query.porn_id) { 84 | let { adapter, type, id } = parsePornId(query.porn_id) 85 | 86 | if (type && query.type && type !== query.type) { 87 | throw new Error( 88 | `Request query and porn_id types do not match (${type}, ${query.type})` 89 | ) 90 | } 91 | 92 | if (adapters.length && !adapters.includes(adapter)) { 93 | throw new Error( 94 | `Request sort and porn_id adapters do not match (${adapter})` 95 | ) 96 | } 97 | 98 | adapters = [adapter] 99 | query.type = type 100 | query.id = id 101 | } 102 | 103 | return { query, adapters, skip, limit } 104 | } 105 | 106 | function normalizeResult(adapter, item, idProp = 'id') { 107 | let newItem = { ...item } 108 | newItem[idProp] = makePornId(adapter.constructor.name, item.type, item.id) 109 | return newItem 110 | } 111 | 112 | function mergeResults(results) { 113 | // TODO: limit 114 | return results.reduce((results, adapterResults) => { 115 | results.push(...adapterResults) 116 | return results 117 | }, []) 118 | } 119 | 120 | 121 | class PornClient { 122 | static ID = ID 123 | static ADAPTERS = ADAPTERS 124 | static SORTS = SORTS 125 | 126 | constructor(options) { 127 | let httpClient = new HttpClient(options) 128 | this.adapters = ADAPTERS.map((Adapter) => new Adapter(httpClient)) 129 | 130 | if (options.cache === '1') { 131 | this.cache = cacheManager.caching({ store: 'memory' }) 132 | } else if (options.cache && options.cache !== '0') { 133 | this.cache = cacheManager.caching({ 134 | store: redisStore, 135 | url: options.cache, 136 | }) 137 | } 138 | } 139 | 140 | _getAdaptersForRequest(request) { 141 | let { query, adapters } = request 142 | let { type } = query 143 | let matchingAdapters = this.adapters 144 | 145 | if (adapters.length) { 146 | matchingAdapters = matchingAdapters.filter((adapter) => { 147 | return adapters.includes(adapter.constructor.name) 148 | }) 149 | } 150 | 151 | if (type) { 152 | matchingAdapters = matchingAdapters.filter((adapter) => { 153 | return adapter.constructor.SUPPORTED_TYPES.includes(type) 154 | }) 155 | } 156 | 157 | return matchingAdapters.slice(0, MAX_ADAPTERS_PER_REQUEST) 158 | } 159 | 160 | async _invokeAdapterMethod(adapter, method, request, idProp) { 161 | let results = await adapter[method](request) 162 | return results.map((result) => { 163 | return normalizeResult(adapter, result, idProp) 164 | }) 165 | } 166 | 167 | // Aggregate method that dispatches requests to matching adapters 168 | async _invokeMethod(methodName, rawRequest, idProp) { 169 | let request = normalizeRequest(rawRequest) 170 | let adapters = this._getAdaptersForRequest(request) 171 | 172 | if (!adapters.length) { 173 | throw new Error('Couldn\'t find suitable adapters for a request') 174 | } 175 | 176 | let results = [] 177 | 178 | for (let adapter of adapters) { 179 | let adapterResults = await this._invokeAdapterMethod( 180 | adapter, methodName, request, idProp 181 | ) 182 | results.push(adapterResults) 183 | } 184 | 185 | return mergeResults(results, request.limit) 186 | } 187 | 188 | // This is a public wrapper around the private method 189 | // that implements caching and result normalization 190 | async invokeMethod(methodName, rawRequest) { 191 | let { adapterMethod, cacheTtl, idProp, expectsArray } = METHODS[methodName] 192 | let invokeMethod = async () => { 193 | let result = await this._invokeMethod(adapterMethod, rawRequest, idProp) 194 | result = expectsArray ? result : result[0] 195 | return result 196 | } 197 | 198 | if (this.cache) { 199 | let cacheKey = CACHE_PREFIX + JSON.stringify(rawRequest) 200 | let cacheOptions = { 201 | ttl: cacheTtl, 202 | } 203 | return this.cache.wrap(cacheKey, invokeMethod, cacheOptions) 204 | } else { 205 | return invokeMethod() 206 | } 207 | } 208 | } 209 | 210 | 211 | export default PornClient 212 | -------------------------------------------------------------------------------- /dist/adapters/PornCom.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../../src/adapters/PornCom.js"],"names":["BASE_URL","API_URL","VIDEOS_API_URL","ITEMS_PER_PAGE","SUPPORTED_TYPES","formatDuration","seconds","Number","minutesString","Math","floor","secondsString","slice","PornCom","BaseAdapter","_normalizeItem","item","type","id","name","title","genre","tags","banner","thumb","poster","posterShape","website","url","description","runtime","duration","undefined","year","Date","active_date","getFullYear","popularity","views","isFree","_normalizeStream","stream","quality","availability","live","_makeEmbedUrl","_makeDownloadUrl","_parseApiResponse","response","JSON","parse","success","Error","message","result","_extractQualitiesFromEmbedPage","body","match","map","filter","_getQualities","embedUrl","httpClient","request","_findByPage","query","page","options","json","limit","search","cats","_getItem","_getStreams","qualities"],"mappings":"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","sourcesContent":["import BaseAdapter from './BaseAdapter'\n\n\nconst BASE_URL = 'https://www.porn.com'\nconst API_URL = 'https://api.porn.com'\nconst VIDEOS_API_URL = `${API_URL}/videos/find.json`\nconst ITEMS_PER_PAGE = 70\nconst SUPPORTED_TYPES = ['movie']\n\n\nfunction formatDuration(seconds) {\n seconds = Number(seconds)\n let minutesString = Math.floor(seconds / 60)\n let secondsString = `0${seconds % 60}`.slice(-2)\n return `${minutesString}:${secondsString}`\n}\n\n\nclass PornCom extends BaseAdapter {\n static DISPLAY_NAME = 'Porn.com'\n static SUPPORTED_TYPES = SUPPORTED_TYPES\n static ITEMS_PER_PAGE = ITEMS_PER_PAGE\n\n _normalizeItem(item) {\n return super._normalizeItem({\n type: 'movie',\n id: item.id,\n name: item.title,\n genre: item.tags,\n banner: item.thumb,\n poster: item.thumb,\n posterShape: 'landscape',\n website: item.url,\n description: item.url,\n runtime: item.duration ? formatDuration(item.duration) : undefined,\n year: new Date(item.active_date).getFullYear(),\n popularity: item.views && Number(item.views),\n isFree: 1,\n })\n }\n\n _normalizeStream(stream) {\n return super._normalizeStream({\n id: stream.id,\n url: stream.url,\n title: `${stream.quality}p`,\n availability: 1,\n live: true,\n isFree: true,\n })\n }\n\n _makeEmbedUrl(id) {\n return `${BASE_URL}/videos/embed/${id}`\n }\n\n _makeDownloadUrl(id, quality) {\n return `${BASE_URL}/download/${quality}/${id}.mp4`\n }\n\n _parseApiResponse(response) {\n if (typeof response === 'string') {\n response = JSON.parse(response)\n }\n\n if (!response.success) {\n throw new Error(response.message)\n }\n\n return response.result\n }\n\n _extractQualitiesFromEmbedPage(body) {\n return body\n .match(/['\"]?id['\"]?:\\s*['\"]\\d+p['\"]/gi) // Find id:\"240p\"\n .map((item) => item.match(/\\d+/)[0]) // Extract 240\n .filter((quality) => Number(quality) < 360) // 360+ are restricted\n }\n\n async _getQualities(id) {\n let embedUrl = this._makeEmbedUrl(id)\n let { body } = await this.httpClient.request(embedUrl)\n return this._extractQualitiesFromEmbedPage(body)\n }\n\n async _findByPage(query, page) {\n let options = {\n json: true,\n query: {\n page,\n limit: ITEMS_PER_PAGE,\n search: query.search,\n cats: query.genre,\n },\n }\n let { body } = await this.httpClient.request(VIDEOS_API_URL, options)\n return this._parseApiResponse(body)\n }\n\n async _getItem(type, id) {\n let options = {\n json: true,\n query: { id, limit: 1 },\n }\n let { body } = await this.httpClient.request(VIDEOS_API_URL, options)\n return this._parseApiResponse(body)[0]\n }\n\n async _getStreams(type, id) {\n let qualities = await this._getQualities(id)\n return qualities.map((quality) => {\n let url = this._makeDownloadUrl(id, quality)\n return { id, url, quality }\n })\n }\n}\n\n\nexport default PornCom\n"],"file":"PornCom.js"} -------------------------------------------------------------------------------- /dist/adapters/EPorner.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _xmlJs = require("xml-js"); 9 | 10 | var _cheerio = _interopRequireDefault(require("cheerio")); 11 | 12 | var _BaseAdapter = _interopRequireDefault(require("./BaseAdapter")); 13 | 14 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 15 | 16 | function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } function _next(value) { step("next", value); } function _throw(err) { step("throw", err); } _next(); }); }; } 17 | 18 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 19 | 20 | const BASE_URL = 'https://www.eporner.com'; 21 | const ITEMS_PER_PAGE = 60; 22 | const SUPPORTED_TYPES = ['movie']; 23 | 24 | class EPorner extends _BaseAdapter.default { 25 | _normalizePageItem(item) { 26 | let id = item.url.split('/')[4]; 27 | let duration = item.duration && item.duration.replace('M', ':').replace(/[TS]/gi, ''); 28 | return { 29 | type: 'movie', 30 | id: id, 31 | name: item.title, 32 | genre: item.tags, 33 | banner: item.image, 34 | poster: item.image, 35 | posterShape: 'landscape', 36 | website: item.url, 37 | description: item.url, 38 | runtime: duration, 39 | isFree: 1 40 | }; 41 | } 42 | 43 | _normalizeApiItem(item) { 44 | let tags = item.keywords && item.keywords._text.split(',').slice(1).map(keyword => keyword.trim()).filter(keyword => keyword.split(' ').length < 3); 45 | 46 | return { 47 | type: 'movie', 48 | id: item.sid ? item.sid._text : item.id._text, 49 | name: item.title._text, 50 | genre: tags, 51 | banner: item.imgthumb._text, 52 | poster: item['imgthumb320x240']._text, 53 | posterShape: 'landscape', 54 | website: item.loc._text, 55 | description: item.loc._text, 56 | runtime: item.lenghtmin._text, 57 | popularity: Number(item.views._text || 0), 58 | isFree: 1 59 | }; 60 | } 61 | 62 | _normalizeItem(item) { 63 | if (item._source === 'moviePage') { 64 | item = this._normalizePageItem(item); 65 | } else { 66 | item = this._normalizeApiItem(item); 67 | } 68 | 69 | return super._normalizeItem(item); 70 | } 71 | 72 | _normalizeStream(stream) { 73 | let quality = stream.url.match(/-(\d+)p/i); 74 | return super._normalizeStream({ 75 | id: stream.id, 76 | url: stream.url, 77 | title: quality ? quality[1] : 'Watch', 78 | availability: 1, 79 | live: true, 80 | isFree: true 81 | }); 82 | } 83 | 84 | _makeApiUrl(query, skip, limit) { 85 | let { 86 | search, 87 | genre 88 | } = query; 89 | let keywords; 90 | 91 | if (search && genre) { 92 | keywords = `${genre},${search}`; 93 | } else { 94 | keywords = search || genre || 'all'; 95 | } 96 | 97 | keywords = keywords.replace(' ', '+'); 98 | return `${BASE_URL}/api_xml/${keywords}/${limit}/${skip}/adddate`; 99 | } 100 | 101 | _makeMovieUrl(id) { 102 | return `${BASE_URL}/hd-porn/${id}`; 103 | } 104 | 105 | _makeVideoDownloadUrl(path) { 106 | return BASE_URL + path; 107 | } 108 | 109 | _parseApiResponse(xml) { 110 | let results = (0, _xmlJs.xml2js)(xml, { 111 | compact: true, 112 | trim: true 113 | })['eporner-data'].movie; 114 | 115 | if (!results) { 116 | return []; 117 | } else if (!Array.isArray(results)) { 118 | return [results]; 119 | } else { 120 | return results; 121 | } 122 | } 123 | 124 | _parseMoviePage(body) { 125 | let $ = _cheerio.default.load(body); 126 | 127 | let title = $('meta[property="og:title"]').attr('content').replace(/(\s*-\s*)?EPORNER/i, ''); 128 | let description = $('meta[property="og:description"]').attr('content'); 129 | let duration = description.match(/duration:\s*((:?\d)+)/i)[1]; 130 | let url = $('meta[property="og:url"]').attr('content'); 131 | let image = $('meta[property="og:image"]').attr('content'); 132 | let tags = $('#hd-porn-tags td').filter((i, item) => $(item).text().trim() === 'Tags:').next().find('a').map((i, item) => $(item).text().trim()).toArray(); 133 | let downloadUrls = $('#hd-porn-dload a').map((i, link) => { 134 | let href = $(link).attr('href'); 135 | return this._makeVideoDownloadUrl(href); 136 | }).toArray(); 137 | return { 138 | _source: 'moviePage', 139 | title, 140 | url, 141 | image, 142 | tags, 143 | duration, 144 | downloadUrls 145 | }; 146 | } 147 | 148 | _find(query, { 149 | skip, 150 | limit 151 | }) { 152 | var _this = this; 153 | 154 | return _asyncToGenerator(function* () { 155 | let url = _this._makeApiUrl(query, skip, limit); 156 | 157 | let { 158 | body 159 | } = yield _this.httpClient.request(url); 160 | return _this._parseApiResponse(body); 161 | })(); 162 | } 163 | 164 | _getItem(type, id) { 165 | var _this2 = this; 166 | 167 | return _asyncToGenerator(function* () { 168 | let url = _this2._makeMovieUrl(id); 169 | 170 | let { 171 | body 172 | } = yield _this2.httpClient.request(url); 173 | return _this2._parseMoviePage(body); 174 | })(); 175 | } 176 | 177 | _getStreams(type, id) { 178 | var _this3 = this; 179 | 180 | return _asyncToGenerator(function* () { 181 | // Video downloads are restricted to 30 per day per guest 182 | let url = _this3._makeMovieUrl(id); 183 | 184 | let { 185 | body 186 | } = yield _this3.httpClient.request(url); 187 | 188 | let { 189 | downloadUrls 190 | } = _this3._parseMoviePage(body); 191 | 192 | let streamUrls = downloadUrls.map(url => { 193 | return _this3.httpClient.request(url, { 194 | followRedirect: false 195 | }); 196 | }); 197 | streamUrls = yield Promise.all(streamUrls); 198 | return streamUrls.map(res => { 199 | return { 200 | id, 201 | url: res.headers.location 202 | }; 203 | }).filter(stream => stream.url); 204 | })(); 205 | } 206 | 207 | } 208 | 209 | _defineProperty(_defineProperty(_defineProperty(EPorner, "DISPLAY_NAME", 'EPorner'), "SUPPORTED_TYPES", SUPPORTED_TYPES), "ITEMS_PER_PAGE", ITEMS_PER_PAGE); 210 | 211 | var _default = EPorner; 212 | exports.default = _default; 213 | //# sourceMappingURL=EPorner.js.map -------------------------------------------------------------------------------- /dist/adapters/Chaturbate.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../../src/adapters/Chaturbate.js"],"names":["BASE_URL","GET_STREAM_URL","ITEMS_PER_PAGE","SUPPORTED_TYPES","Chaturbate","BaseAdapter","_normalizeItem","item","type","id","name","genre","tags","banner","poster","posterShape","website","url","description","subject","popularity","viewers","isFree","_normalizeStream","stream","title","availability","live","_parseListPage","body","$","cheerio","load","tagRegexp","map","i","$item","$link","find","text","trim","attr","match","tag","slice","Number","toArray","_parseItemPage","split","_findByPage","query","page","options","keywords","search","httpClient","request","_getItem","_getStreams","form","json","method","headers","Referer","room_slug","bandwidth","success","room_status"],"mappings":"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","sourcesContent":["import cheerio from 'cheerio'\nimport BaseAdapter from './BaseAdapter'\n\n\nconst BASE_URL = 'https://chaturbate.com'\nconst GET_STREAM_URL = 'https://chaturbate.com/get_edge_hls_url_ajax/'\n// Chaturbate's number of items per page varies from load to load,\n// so this is the minimum number\nconst ITEMS_PER_PAGE = 60\nconst SUPPORTED_TYPES = ['tv']\n\n\nclass Chaturbate extends BaseAdapter {\n static DISPLAY_NAME = 'Chaturbate'\n static SUPPORTED_TYPES = SUPPORTED_TYPES\n static ITEMS_PER_PAGE = ITEMS_PER_PAGE\n\n _normalizeItem(item) {\n return super._normalizeItem({\n type: 'tv',\n id: item.id,\n name: item.id,\n genre: item.tags,\n banner: item.poster,\n poster: item.poster,\n posterShape: 'landscape',\n website: item.url,\n description: item.subject,\n popularity: item.viewers,\n isFree: true,\n })\n }\n\n _normalizeStream(stream) {\n return super._normalizeStream({\n ...stream,\n title: 'Watch',\n availability: 1,\n live: true,\n isFree: true,\n })\n }\n\n _parseListPage(body) {\n let $ = cheerio.load(body)\n let tagRegexp = /#\\S+/g\n return $('.list > li').map((i, item) => {\n let $item = $(item)\n let $link = $item.find('.title > a')\n let id = $link.text().trim()\n let url = BASE_URL + $link.attr('href')\n let subject = $item.find('.subject').text().trim()\n let tags = (subject.match(tagRegexp) || []).map((tag) => tag.slice(1))\n let poster = $item.find('img').attr('src')\n let viewers = $item.find('.cams').text().match(/(\\d+) viewers/i)\n viewers = viewers && Number(viewers[1])\n return { id, url, subject, poster, tags, viewers }\n }).toArray()\n }\n\n _parseItemPage(body) {\n let $ = cheerio.load(body)\n let tagRegexp = /#\\S+/g\n let url = $('meta[property=\"og:url\"]').attr('content')\n let id = url.split('/').slice(-2, -1)[0]\n let subject = $('meta[property=\"og:description\"]').attr('content').trim()\n let tags = (subject.match(tagRegexp) || []).map((tag) => tag.slice(1))\n let poster = $('meta[property=\"og:image\"]').attr('content')\n return { id, url, subject, poster, tags }\n }\n\n async _findByPage(query, page) {\n let options = {\n query: {\n page,\n keywords: query.search,\n },\n }\n let url = query.genre ? `${BASE_URL}/tag/${query.genre}` : BASE_URL\n let { body } = await this.httpClient.request(url, options)\n return this._parseListPage(body)\n }\n\n async _getItem(type, id) {\n let url = `${BASE_URL}/${id}`\n let { body } = await this.httpClient.request(url)\n return this._parseItemPage(body)\n }\n\n async _getStreams(type, id) {\n let options = {\n form: true,\n json: true,\n method: 'post',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n 'X-Requested-With': 'XMLHttpRequest',\n Referer: `${BASE_URL}/${id}`,\n },\n body: {\n /* eslint-disable-next-line camelcase */\n room_slug: id,\n bandwidth: 'high',\n },\n }\n let { body } = await this.httpClient.request(GET_STREAM_URL, options)\n\n if (body.success && body.room_status === 'public') {\n return [{ id, url: body.url }]\n } else {\n return []\n }\n }\n}\n\n\nexport default Chaturbate\n"],"file":"Chaturbate.js"} -------------------------------------------------------------------------------- /dist/adapters/BaseAdapter.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../../src/adapters/BaseAdapter.js"],"names":["BaseAdapter","constructor","httpClient","scheduler","Bottleneck","maxConcurrent","MAX_CONCURRENT_REQUESTS","_normalizeItem","item","_normalizeStream","stream","name","_paginate","request","itemsPerPage","ITEMS_PER_PAGE","Infinity","skip","limit","Math","min","MAX_RESULTS_PER_REQUEST","firstPage","ceil","pageCount","pages","i","length","push","skipOnFirstPage","_validateRequest","typeRequired","SUPPORTED_TYPES","Error","query","type","includes","_find","pagination","requests","map","page","_findByPage","results","Promise","all","concat","filter","slice","find","schedule","getItem","id","result","_getItem","getStreams","_getStreams"],"mappings":"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","sourcesContent":["import Bottleneck from 'bottleneck'\n\n\n// Contains some common methods as well as public wrappers\n// that prepare requests, redirect them to private methods\n// and normalize results\nclass BaseAdapter {\n static SUPPORTED_TYPES = []\n static MAX_RESULTS_PER_REQUEST = 100\n static MAX_CONCURRENT_REQUESTS = 3\n\n constructor(httpClient) {\n this.httpClient = httpClient\n this.scheduler = new Bottleneck({\n maxConcurrent: this.constructor.MAX_CONCURRENT_REQUESTS,\n })\n }\n\n _normalizeItem(item) {\n return item\n }\n\n _normalizeStream(stream) {\n if (stream.name) {\n return stream\n } else {\n return { ...stream, name: this.constructor.name }\n }\n }\n\n _paginate(request) {\n let itemsPerPage = this.constructor.ITEMS_PER_PAGE || Infinity\n let { skip = 0, limit = itemsPerPage } = request\n limit = Math.min(limit, this.constructor.MAX_RESULTS_PER_REQUEST)\n itemsPerPage = Math.min(itemsPerPage, limit)\n\n let firstPage = Math.ceil((skip + 0.1) / itemsPerPage) || 1\n let pageCount = Math.ceil(limit / itemsPerPage)\n let pages = []\n\n for (let i = firstPage; pages.length < pageCount; i++) {\n pages.push(i)\n }\n\n return {\n pages, skip, limit,\n skipOnFirstPage: skip % itemsPerPage,\n }\n }\n\n _validateRequest(request, typeRequired) {\n let { SUPPORTED_TYPES } = this.constructor\n\n if (typeof request !== 'object') {\n throw new Error(`A request must be an object, ${typeof request} given`)\n }\n\n if (!request.query) {\n throw new Error('Request query must not be empty')\n }\n\n if (typeRequired && !request.query.type) {\n throw new Error('Content type must be specified')\n }\n\n if (request.query.type && !SUPPORTED_TYPES.includes(request.query.type)) {\n throw new Error(`Content type ${request.query.type} is not supported`)\n }\n }\n\n async _find(query, pagination) {\n let { pages, limit, skipOnFirstPage } = pagination\n let requests = pages.map((page) => {\n return this._findByPage(query, page)\n })\n\n let results = await Promise.all(requests)\n results = [].concat(...results).filter((item) => item)\n return results.slice(skipOnFirstPage, skipOnFirstPage + limit)\n }\n\n async find(request) {\n this._validateRequest(request)\n\n let pagination = this._paginate(request)\n let { query } = request\n\n if (!query.type) {\n query = {\n ...query,\n type: this.constructor.SUPPORTED_TYPES[0],\n }\n }\n\n let results = await this.scheduler.schedule(() => {\n return this._find(query, pagination)\n })\n\n if (results) {\n return results.map((item) => this._normalizeItem(item))\n } else {\n return []\n }\n }\n\n async getItem(request) {\n this._validateRequest(request, true)\n\n let { type, id } = request.query\n let result = await this.scheduler.schedule(() => {\n return this._getItem(type, id)\n })\n return result ? [this._normalizeItem(result)] : []\n }\n\n async getStreams(request) {\n this._validateRequest(request, true)\n\n let { type, id } = request.query\n let results = await this.scheduler.schedule(() => {\n return this._getStreams(type, id)\n })\n\n if (results) {\n return results.map((stream) => this._normalizeStream(stream))\n } else {\n return []\n }\n }\n}\n\n\nexport default BaseAdapter\n"],"file":"BaseAdapter.js"} -------------------------------------------------------------------------------- /tests/adapters/SpankWire/embeddedMoviePage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Spankwire Embed Player 6 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 | 41 |
42 | 43 | 51 |
52 | 53 | 188 | 189 | 200 | 201 | -------------------------------------------------------------------------------- /dist/adapters/HubTrafficAdapter.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../../src/adapters/HubTrafficAdapter.js"],"names":["HubTrafficAdapter","BaseAdapter","_normalizeItem","item","video","TAGS_TO_SKIP","constructor","tags","Object","values","map","tag","tag_name","filter","includes","toLowerCase","type","id","video_id","name","title","trim","genre","banner","thumb","poster","posterShape","year","publish_date","split","website","url","description","runtime","duration","popularity","Number","views","isFree","_normalizeStream","stream","quality","availability","_makeMethodUrl","Error","_makeEmbedUrl","_extractStreamsFromEmbed","_requestApi","method","params","options","json","URL","keys","forEach","undefined","searchParams","set","body","httpClient","request","code","err","message","_findByPage","query","page","ITEMS_PER_PAGE","newQuery","search","period","ordering","thumbsize","result","videos","length","monthlyVideos","concat","slice","_getItem","VIDEO_ID_PARAMETER","_getStreams","streams"],"mappings":"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","sourcesContent":["import { URL } from 'url'\nimport BaseAdapter from './BaseAdapter'\n\n\n// https://www.hubtraffic.com/\nclass HubTrafficAdapter extends BaseAdapter {\n static SUPPORTED_TYPES = ['movie']\n static TAGS_TO_SKIP = []\n static VIDEO_ID_PARAMETER = 'video_id'\n\n _normalizeItem(item) {\n let video = item.video || item\n let { TAGS_TO_SKIP } = this.constructor\n let tags = video.tags && Object.values(video.tags)\n .map((tag) => {\n return (typeof tag === 'string') ? tag : tag.tag_name\n })\n .filter((tag) => !TAGS_TO_SKIP.includes(tag.toLowerCase()))\n\n return super._normalizeItem({\n type: 'movie',\n id: video.video_id || video.id,\n name: video.title.trim(),\n genre: tags,\n banner: video.thumb,\n poster: video.thumb,\n posterShape: 'landscape',\n year: video.publish_date && video.publish_date.split('-')[0],\n website: video.url,\n description: video.url,\n runtime: video.duration,\n popularity: Number(video.views),\n isFree: 1,\n })\n }\n\n _normalizeStream(stream) {\n let title =\n (stream.title && stream.title.trim()) ||\n (stream.quality && stream.quality.trim()) ||\n 'SD'\n\n return super._normalizeStream({\n ...stream,\n title,\n availability: 1,\n isFree: 1,\n })\n }\n\n _makeMethodUrl() {\n throw new Error('Not implemented')\n }\n\n _makeEmbedUrl() {\n throw new Error('Not implemented')\n }\n\n _extractStreamsFromEmbed() {\n throw new Error('Not implemented')\n }\n\n async _requestApi(method, params) {\n let options = {\n json: true,\n }\n let url = this._makeMethodUrl(method)\n\n if (params) {\n url = new URL(url)\n Object.keys(params).forEach((name) => {\n if (params[name] !== undefined) {\n url.searchParams.set(name, params[name])\n }\n })\n }\n\n let { body } = await this.httpClient.request(url, options)\n\n // Ignore \"No Videos found!\"\" and \"No video with this ID.\" errors\n // eslint-disable-next-line eqeqeq\n if (body.code && body.code != 2001 && body.code != 2002) {\n let err = new Error(body.message)\n err.code = Number(body.code)\n throw err\n }\n\n return body\n }\n\n async _findByPage(query, page) {\n let { ITEMS_PER_PAGE } = this.constructor\n let newQuery = {\n 'tags[]': query.genre,\n search: query.search,\n period: 'weekly',\n ordering: 'mostviewed',\n thumbsize: 'medium',\n page,\n }\n\n let result = await this._requestApi('searchVideos', newQuery)\n let videos = result.videos || result.video || []\n\n // We retry with the monthly period in case there are too few weekly videos\n if (!query.search && page === 1 && videos.length < ITEMS_PER_PAGE) {\n newQuery.period = 'monthly'\n let result = await this._requestApi('searchVideos', newQuery)\n let monthlyVideos = result.videos || result.video || []\n videos = videos.concat(monthlyVideos).slice(0, ITEMS_PER_PAGE)\n }\n\n return videos\n }\n\n async _getItem(type, id) {\n let query = {\n [this.constructor.VIDEO_ID_PARAMETER]: id,\n }\n\n return this._requestApi('getVideoById', query)\n }\n\n async _getStreams(type, id) {\n let url = this._makeEmbedUrl(id)\n let { body } = await this.httpClient.request(url)\n\n let streams = this._extractStreamsFromEmbed(body)\n return streams && streams.map((stream) => {\n stream.id = id\n return stream\n })\n }\n}\n\n\nexport default HubTrafficAdapter\n"],"file":"HubTrafficAdapter.js"} -------------------------------------------------------------------------------- /dist/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../src/index.js"],"names":["SUPPORTED_METHODS","STATIC_DIR","DEFAULT_ID","ID","process","env","STREMIO_PORN_ID","ENDPOINT","STREMIO_PORN_ENDPOINT","PORT","STREMIO_PORN_PORT","PROXY","STREMIO_PORN_PROXY","HTTPS_PROXY","CACHE","STREMIO_PORN_CACHE","REDIS_URL","EMAIL","STREMIO_PORN_EMAIL","IS_PROD","NODE_ENV","console","error","chalk","red","exit","availableSites","PornClient","ADAPTERS","map","a","DISPLAY_NAME","join","MANIFEST","name","id","version","pkg","description","types","idProperty","dontAnnounce","sorts","SORTS","email","contactEmail","endpoint","logo","icon","background","filter","$exists","$in","makeMethod","client","methodName","request","cb","response","invokeMethod","err","gray","Date","toLocaleString","makeMethods","methodNames","reduce","methods","proxy","cache","addon","Stremio","Server","server","http","createServer","req","res","middleware","end","on","values","green","log","listen"],"mappings":"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","sourcesContent":["import http from 'http'\nimport Stremio from 'stremio-addons'\nimport serveStatic from 'serve-static'\nimport chalk from 'chalk'\nimport pkg from '../package.json'\nimport PornClient from './PornClient'\n\n\nconst SUPPORTED_METHODS = [\n 'stream.find', 'meta.find', 'meta.search', 'meta.get',\n]\nconst STATIC_DIR = 'static'\nconst DEFAULT_ID = 'stremio_porn'\n\nconst ID = process.env.STREMIO_PORN_ID || DEFAULT_ID\nconst ENDPOINT = process.env.STREMIO_PORN_ENDPOINT || 'http://localhost'\nconst PORT = process.env.STREMIO_PORN_PORT || process.env.PORT || '80'\nconst PROXY = process.env.STREMIO_PORN_PROXY || process.env.HTTPS_PROXY\nconst CACHE = process.env.STREMIO_PORN_CACHE || process.env.REDIS_URL || '1'\nconst EMAIL = process.env.STREMIO_PORN_EMAIL || process.env.EMAIL\nconst IS_PROD = process.env.NODE_ENV === 'production'\n\n\nif (IS_PROD && ID === DEFAULT_ID) {\n // eslint-disable-next-line no-console\n console.error(\n chalk.red(\n '\\nWhen running in production, a non-default addon identifier must be specified\\n'\n )\n )\n process.exit(1)\n}\n\nlet availableSites = PornClient.ADAPTERS.map((a) => a.DISPLAY_NAME).join(', ')\n\nconst MANIFEST = {\n name: 'Porn',\n id: ID,\n version: pkg.version,\n description: `\\\nTime to unsheathe your sword! \\\nWatch porn videos and webcam streams from ${availableSites}\\\n`,\n types: ['movie', 'tv'],\n idProperty: PornClient.ID,\n dontAnnounce: !IS_PROD,\n sorts: PornClient.SORTS,\n // The docs mention `contactEmail`, but the template uses `email`\n email: EMAIL,\n contactEmail: EMAIL,\n endpoint: `${ENDPOINT}/stremioget/stremio/v1`,\n logo: `${ENDPOINT}/logo.png`,\n icon: `${ENDPOINT}/logo.png`,\n background: `${ENDPOINT}/bg.jpg`,\n // OBSOLETE: used in pre-4.0 stremio instead of idProperty/types\n filter: {\n [`query.${PornClient.ID}`]: { $exists: true },\n 'query.type': { $in: ['movie', 'tv'] },\n },\n}\n\n\nfunction makeMethod(client, methodName) {\n return async (request, cb) => {\n let response\n let error\n\n try {\n response = await client.invokeMethod(methodName, request)\n } catch (err) {\n error = err\n\n /* eslint-disable no-console */\n console.error(\n // eslint-disable-next-line prefer-template\n chalk.gray(new Date().toLocaleString()) +\n ' An error has occurred while processing ' +\n `the following request to ${methodName}:`\n )\n console.error(request)\n console.error(err)\n /* eslint-enable no-console */\n }\n\n cb(error, response)\n }\n}\n\nfunction makeMethods(client, methodNames) {\n return methodNames.reduce((methods, methodName) => {\n methods[methodName] = makeMethod(client, methodName)\n return methods\n }, {})\n}\n\n\nlet client = new PornClient({ proxy: PROXY, cache: CACHE })\nlet methods = makeMethods(client, SUPPORTED_METHODS)\nlet addon = new Stremio.Server(methods, MANIFEST)\nlet server = http.createServer((req, res) => {\n serveStatic(STATIC_DIR)(req, res, () => {\n addon.middleware(req, res, () => res.end())\n })\n})\n\nserver\n .on('listening', () => {\n let values = {\n endpoint: chalk.green(MANIFEST.endpoint),\n id: ID === DEFAULT_ID ? chalk.red(ID) : chalk.green(ID),\n email: EMAIL ? chalk.green(EMAIL) : chalk.red('undefined'),\n env: IS_PROD ? chalk.green('production') : chalk.green('development'),\n proxy: PROXY ? chalk.green(PROXY) : chalk.red('off'),\n cache: (CACHE === '0') ?\n chalk.red('off') :\n chalk.green(CACHE === '1' ? 'on' : CACHE),\n }\n\n // eslint-disable-next-line no-console\n console.log(`\n ${MANIFEST.name} Addon is listening on port ${PORT}\n\n Endpoint: ${values.endpoint}\n Addon Id: ${values.id}\n Email: ${values.email}\n Environment: ${values.env}\n Proxy: ${values.proxy}\n Cache: ${values.cache}\n `)\n })\n .listen(PORT)\n\n\nexport default server\n"],"file":"index.js"} -------------------------------------------------------------------------------- /tests/adapters/EPorner/apiResponse.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 1562275 8 | EjHSX22iLQp 9 | Teen With A Hairy Pussy Gets Pounded Hard 10 | , teens, blonde, amateur, homemade, Teen with a hairy pussy gets pounded hard, cumshot, hardcore, lingerie 11 | 9236 12 | https://www.eporner.com/hd-porn/EjHSX22iLQp/Teen-With-A-Hairy-Pussy-Gets-Pounded-Hard/ 13 | https://static-eu-cdn.eporner.com/thumbs/static4/1/15/156/1562275/14.jpg 14 | https://imggen.eporner.com/1562275/320/240/14.jpg 15 | 16 | 17 | 554 18 | 9:14 19 | ]]> 20 | 21 | 22 | 1526013 23 | wQtJT94zlPe 24 | ABIGAILE JOHNSON - TEEN RIDES DICK 25 | , pornstar, students, uniform, teens, ABIGAILE JOHNSON - TEEN, blonde, pov 26 | 113955 27 | https://www.eporner.com/hd-porn/wQtJT94zlPe/ABIGAILE-JOHNSON-TEEN-RIDES-DICK/ 28 | https://static-eu-cdn.eporner.com/thumbs/static4/1/15/152/1526013/14.jpg 29 | https://imggen.eporner.com/1526013/320/240/14.jpg 30 | 31 | 32 | 1530 33 | 25:30 34 | ]]> 35 | 36 | 37 | 1550946 38 | hX7I8jySfWG 39 | Kinky Babe With Playful Feet 40 | , blonde, cumshot, hardcore, footjob, fetish, lingerie, big tits, Britney Amber 41 | 6505 42 | https://www.eporner.com/hd-porn/hX7I8jySfWG/Kinky-Babe-With-Playful-Feet/ 43 | https://static-eu-cdn.eporner.com/thumbs/static4/1/15/155/1550946/11.jpg 44 | https://imggen.eporner.com/1550946/320/240/11.jpg 45 | 46 | 47 | 3557 48 | 59:17 49 | ]]> 50 | 51 | 52 | 1555123 53 | sPFksvGDIR5 54 | Daisy Stone Anal 55 | , big ass, anal, Daisy Stone, blonde, hardcore, cumshot, creampie 56 | 41623 57 | https://www.eporner.com/hd-porn/sPFksvGDIR5/Daisy-Stone-Anal/ 58 | https://static-eu-cdn.eporner.com/thumbs/static4/1/15/155/1555123/13.jpg 59 | https://imggen.eporner.com/1555123/320/240/13.jpg 60 | 61 | 62 | 2204 63 | 36:44 64 | ]]> 65 | 66 | 67 | 1562410 68 | 4LKq097fVBx 69 | Dakota Skye Gangbang 70 | , anal, blonde, Dakota Skye gangbang , double penetration, cumshot, interracial, big dick, group sex 71 | 5973 72 | https://www.eporner.com/hd-porn/4LKq097fVBx/Dakota-Skye-Gangbang/ 73 | https://static-eu-cdn.eporner.com/thumbs/static4/1/15/156/1562410/14.jpg 74 | https://imggen.eporner.com/1562410/320/240/14.jpg 75 | 76 | 77 | 2502 78 | 41:42 79 | ]]> 80 | 81 | 82 | 1545503 83 | trSNBWh6nej 84 | BLACKED Girlfriend Cheats With BBC Crush And Gets Dominated 85 | hd sex, brunette,big tits,cheating,cheat,pussy licking,licking pussy,hairy bush,hairy pussy,prone bone,riding,cowgirl,doggystyle,big dick,big cock,bbc,interracial, BLACKED Girlfriend Cheats With BBC Crush And Gets Dominated, hardcore, Quinn Wilde 86 | 54492 87 | https://www.eporner.com/hd-porn/trSNBWh6nej/BLACKED-Girlfriend-Cheats-With-BBC-Crush-And-Gets-Dominated/ 88 | https://static-eu-cdn.eporner.com/thumbs/static4/1/15/154/1545503/12.jpg 89 | https://imggen.eporner.com/1545503/320/240/12.jpg 90 | 91 | 92 | 707 93 | 11:47 94 | ]]> 95 | 96 | 97 | 1550936 98 | grkfDLTwIy9 99 | Three Horny Sluts Know How To Use Hard Cockc 100 | , brunette, lingerie, group sex, cumshot, hardcore, Katrina Jade, Kristina Rose, Jade Rose, Kissa Sins 101 | 10853 102 | https://www.eporner.com/hd-porn/grkfDLTwIy9/Three-Horny-Sluts-Know-How-To-Use-Hard-Cockc/ 103 | https://static-eu-cdn.eporner.com/thumbs/static4/1/15/155/1550936/8.jpg 104 | https://imggen.eporner.com/1550936/320/240/8.jpg 105 | 106 | 107 | 3180 108 | 53:00 109 | ]]> 110 | 111 | 112 | 1554546 113 | AovXOi0kfdH 114 | Busty Housewife Fucked Hard 115 | , big tits, brunette, cumshot, hardcore, lingerie, housewives, milf, pornstar, Sheridan Love 116 | 7528 117 | https://www.eporner.com/hd-porn/AovXOi0kfdH/Busty-Housewife-Fucked-Hard/ 118 | https://static-eu-cdn.eporner.com/thumbs/static4/1/15/155/1554546/7.jpg 119 | https://imggen.eporner.com/1554546/320/240/7.jpg 120 | 121 | 122 | 2373 123 | 39:33 124 | ]]> 125 | 126 | 127 | 1544759 128 | k53ycSMfCKT 129 | Aarin Exploited College Girls 130 | , anal, hd sex, hq porn, pornstar, swingers, students, small tits, Aarin Exploited College Girls, brunette, lingerie, pov, toys, Luna Lovely 131 | 79613 132 | https://www.eporner.com/hd-porn/k53ycSMfCKT/Aarin-Exploited-College-Girls/ 133 | https://static-eu-cdn.eporner.com/thumbs/static4/1/15/154/1544759/11.jpg 134 | https://imggen.eporner.com/1544759/320/240/11.jpg 135 | 136 | 137 | 5485 138 | 91:25 139 | ]]> 140 | 141 | 142 | 1565028 143 | cQMBIzoUvH6 144 | TUSHY Personal Shopper Gets Ass Dominated By Client 145 | , anal, brunette, TUSHY Personal Shopper Gets Ass Dominated By Client, hardcore, cumshot, Gina Valentina, Gina Valentino 146 | 6775 147 | https://www.eporner.com/hd-porn/cQMBIzoUvH6/TUSHY-Personal-Shopper-Gets-Ass-Dominated-By-Client/ 148 | https://static-eu-cdn.eporner.com/thumbs/static4/1/15/156/1565028/10.jpg 149 | https://imggen.eporner.com/1565028/320/240/10.jpg 150 | 151 | 152 | 724 153 | 12:04 154 | ]]> 155 | 156 | 157 | -------------------------------------------------------------------------------- /dist/PornClient.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _cacheManager = _interopRequireDefault(require("cache-manager")); 9 | 10 | var _cacheManagerRedisStore = _interopRequireDefault(require("cache-manager-redis-store")); 11 | 12 | var _HttpClient = _interopRequireDefault(require("./HttpClient")); 13 | 14 | var _PornHub = _interopRequireDefault(require("./adapters/PornHub")); 15 | 16 | var _RedTube = _interopRequireDefault(require("./adapters/RedTube")); 17 | 18 | var _YouPorn = _interopRequireDefault(require("./adapters/YouPorn")); 19 | 20 | var _SpankWire = _interopRequireDefault(require("./adapters/SpankWire")); 21 | 22 | var _PornCom = _interopRequireDefault(require("./adapters/PornCom")); 23 | 24 | var _Chaturbate = _interopRequireDefault(require("./adapters/Chaturbate")); 25 | 26 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 27 | 28 | function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } function _next(value) { step("next", value); } function _throw(err) { step("throw", err); } _next(); }); }; } 29 | 30 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === 'function') { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function (key) { _defineProperty(target, key, source[key]); }); } return target; } 31 | 32 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 33 | 34 | // EPorner has restricted video downloads to 30 per day per guest 35 | // import EPorner from './adapters/EPorner' 36 | const ID = 'porn_id'; 37 | const SORT_PROP_PREFIX = 'popularities.porn.'; 38 | const CACHE_PREFIX = 'stremio-porn|'; // Making multiple requests to multiple adapters for different types 39 | // and then aggregating them is a lot of work, 40 | // so we only support 1 adapter per request for now. 41 | 42 | const MAX_ADAPTERS_PER_REQUEST = 1; 43 | const ADAPTERS = [_PornHub.default, _RedTube.default, _YouPorn.default, _SpankWire.default, _PornCom.default, _Chaturbate.default]; 44 | const SORTS = ADAPTERS.map(({ 45 | name, 46 | DISPLAY_NAME, 47 | SUPPORTED_TYPES 48 | }) => ({ 49 | name: `Porn: ${DISPLAY_NAME}`, 50 | prop: `${SORT_PROP_PREFIX}${name}`, 51 | types: SUPPORTED_TYPES 52 | })); 53 | const METHODS = { 54 | 'stream.find': { 55 | adapterMethod: 'getStreams', 56 | cacheTtl: 300, 57 | idProp: ID, 58 | expectsArray: true 59 | }, 60 | 'meta.find': { 61 | adapterMethod: 'find', 62 | cacheTtl: 300, 63 | idProp: 'id', 64 | expectsArray: true 65 | }, 66 | 'meta.search': { 67 | adapterMethod: 'find', 68 | cacheTtl: 3600, 69 | idProp: 'id', 70 | expectsArray: true 71 | }, 72 | 'meta.get': { 73 | adapterMethod: 'getItem', 74 | cacheTtl: 300, 75 | idProp: 'id', 76 | expectsArray: false 77 | } 78 | }; 79 | 80 | function makePornId(adapter, type, id) { 81 | return `${ID}:${adapter}-${type}-${id}`; 82 | } 83 | 84 | function parsePornId(pornId) { 85 | let [adapter, type, id] = pornId.split(':').pop().split('-'); 86 | return { 87 | adapter, 88 | type, 89 | id 90 | }; 91 | } 92 | 93 | function normalizeRequest(request) { 94 | let { 95 | query, 96 | sort, 97 | limit, 98 | skip 99 | } = request; 100 | let adapters = []; 101 | 102 | if (sort) { 103 | adapters = Object.keys(sort).filter(p => p.startsWith(SORT_PROP_PREFIX)).map(p => p.slice(SORT_PROP_PREFIX.length)); 104 | } 105 | 106 | if (typeof query === 'string') { 107 | query = { 108 | search: query 109 | }; 110 | } else if (query) { 111 | query = _objectSpread({}, query); 112 | } else { 113 | query = {}; 114 | } 115 | 116 | if (query.porn_id) { 117 | let { 118 | adapter, 119 | type, 120 | id 121 | } = parsePornId(query.porn_id); 122 | 123 | if (type && query.type && type !== query.type) { 124 | throw new Error(`Request query and porn_id types do not match (${type}, ${query.type})`); 125 | } 126 | 127 | if (adapters.length && !adapters.includes(adapter)) { 128 | throw new Error(`Request sort and porn_id adapters do not match (${adapter})`); 129 | } 130 | 131 | adapters = [adapter]; 132 | query.type = type; 133 | query.id = id; 134 | } 135 | 136 | return { 137 | query, 138 | adapters, 139 | skip, 140 | limit 141 | }; 142 | } 143 | 144 | function normalizeResult(adapter, item, idProp = 'id') { 145 | let newItem = _objectSpread({}, item); 146 | 147 | newItem[idProp] = makePornId(adapter.constructor.name, item.type, item.id); 148 | return newItem; 149 | } 150 | 151 | function mergeResults(results) { 152 | // TODO: limit 153 | return results.reduce((results, adapterResults) => { 154 | results.push(...adapterResults); 155 | return results; 156 | }, []); 157 | } 158 | 159 | class PornClient { 160 | constructor(options) { 161 | let httpClient = new _HttpClient.default(options); 162 | this.adapters = ADAPTERS.map(Adapter => new Adapter(httpClient)); 163 | 164 | if (options.cache === '1') { 165 | this.cache = _cacheManager.default.caching({ 166 | store: 'memory' 167 | }); 168 | } else if (options.cache && options.cache !== '0') { 169 | this.cache = _cacheManager.default.caching({ 170 | store: _cacheManagerRedisStore.default, 171 | url: options.cache 172 | }); 173 | } 174 | } 175 | 176 | _getAdaptersForRequest(request) { 177 | let { 178 | query, 179 | adapters 180 | } = request; 181 | let { 182 | type 183 | } = query; 184 | let matchingAdapters = this.adapters; 185 | 186 | if (adapters.length) { 187 | matchingAdapters = matchingAdapters.filter(adapter => { 188 | return adapters.includes(adapter.constructor.name); 189 | }); 190 | } 191 | 192 | if (type) { 193 | matchingAdapters = matchingAdapters.filter(adapter => { 194 | return adapter.constructor.SUPPORTED_TYPES.includes(type); 195 | }); 196 | } 197 | 198 | return matchingAdapters.slice(0, MAX_ADAPTERS_PER_REQUEST); 199 | } 200 | 201 | _invokeAdapterMethod(adapter, method, request, idProp) { 202 | return _asyncToGenerator(function* () { 203 | let results = yield adapter[method](request); 204 | return results.map(result => { 205 | return normalizeResult(adapter, result, idProp); 206 | }); 207 | })(); 208 | } // Aggregate method that dispatches requests to matching adapters 209 | 210 | 211 | _invokeMethod(methodName, rawRequest, idProp) { 212 | var _this = this; 213 | 214 | return _asyncToGenerator(function* () { 215 | let request = normalizeRequest(rawRequest); 216 | 217 | let adapters = _this._getAdaptersForRequest(request); 218 | 219 | if (!adapters.length) { 220 | throw new Error('Couldn\'t find suitable adapters for a request'); 221 | } 222 | 223 | let results = []; 224 | 225 | for (let adapter of adapters) { 226 | let adapterResults = yield _this._invokeAdapterMethod(adapter, methodName, request, idProp); 227 | results.push(adapterResults); 228 | } 229 | 230 | return mergeResults(results, request.limit); 231 | })(); 232 | } // This is a public wrapper around the private method 233 | // that implements caching and result normalization 234 | 235 | 236 | invokeMethod(methodName, rawRequest) { 237 | var _this2 = this; 238 | 239 | return _asyncToGenerator(function* () { 240 | let { 241 | adapterMethod, 242 | cacheTtl, 243 | idProp, 244 | expectsArray 245 | } = METHODS[methodName]; 246 | 247 | let invokeMethod = 248 | /*#__PURE__*/ 249 | function () { 250 | var _ref = _asyncToGenerator(function* () { 251 | let result = yield _this2._invokeMethod(adapterMethod, rawRequest, idProp); 252 | result = expectsArray ? result : result[0]; 253 | return result; 254 | }); 255 | 256 | return function invokeMethod() { 257 | return _ref.apply(this, arguments); 258 | }; 259 | }(); 260 | 261 | if (_this2.cache) { 262 | let cacheKey = CACHE_PREFIX + JSON.stringify(rawRequest); 263 | let cacheOptions = { 264 | ttl: cacheTtl 265 | }; 266 | return _this2.cache.wrap(cacheKey, invokeMethod, cacheOptions); 267 | } else { 268 | return invokeMethod(); 269 | } 270 | })(); 271 | } 272 | 273 | } 274 | 275 | _defineProperty(_defineProperty(_defineProperty(PornClient, "ID", ID), "ADAPTERS", ADAPTERS), "SORTS", SORTS); 276 | 277 | var _default = PornClient; 278 | exports.default = _default; 279 | //# sourceMappingURL=PornClient.js.map -------------------------------------------------------------------------------- /dist/adapters/EPorner.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../../src/adapters/EPorner.js"],"names":["BASE_URL","ITEMS_PER_PAGE","SUPPORTED_TYPES","EPorner","BaseAdapter","_normalizePageItem","item","id","url","split","duration","replace","type","name","title","genre","tags","banner","image","poster","posterShape","website","description","runtime","isFree","_normalizeApiItem","keywords","_text","slice","map","keyword","trim","filter","length","sid","imgthumb","loc","lenghtmin","popularity","Number","views","_normalizeItem","_source","_normalizeStream","stream","quality","match","availability","live","_makeApiUrl","query","skip","limit","search","_makeMovieUrl","_makeVideoDownloadUrl","path","_parseApiResponse","xml","results","compact","movie","Array","isArray","_parseMoviePage","body","$","cheerio","load","attr","i","text","next","find","toArray","downloadUrls","link","href","_find","httpClient","request","_getItem","_getStreams","streamUrls","followRedirect","Promise","all","res","headers","location"],"mappings":"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","sourcesContent":["import { xml2js } from 'xml-js'\nimport cheerio from 'cheerio'\nimport BaseAdapter from './BaseAdapter'\n\n\nconst BASE_URL = 'https://www.eporner.com'\nconst ITEMS_PER_PAGE = 60\nconst SUPPORTED_TYPES = ['movie']\n\n\nclass EPorner extends BaseAdapter {\n static DISPLAY_NAME = 'EPorner'\n static SUPPORTED_TYPES = SUPPORTED_TYPES\n static ITEMS_PER_PAGE = ITEMS_PER_PAGE\n\n _normalizePageItem(item) {\n let id = item.url.split('/')[4]\n let duration = item.duration && item.duration\n .replace('M', ':')\n .replace(/[TS]/gi, '')\n\n return {\n type: 'movie',\n id: id,\n name: item.title,\n genre: item.tags,\n banner: item.image,\n poster: item.image,\n posterShape: 'landscape',\n website: item.url,\n description: item.url,\n runtime: duration,\n isFree: 1,\n }\n }\n\n _normalizeApiItem(item) {\n let tags = item.keywords && item.keywords._text\n .split(',')\n .slice(1)\n .map((keyword) => keyword.trim())\n .filter((keyword) => keyword.split(' ').length < 3)\n\n return {\n type: 'movie',\n id: item.sid ? item.sid._text : item.id._text,\n name: item.title._text,\n genre: tags,\n banner: item.imgthumb._text,\n poster: item['imgthumb320x240']._text,\n posterShape: 'landscape',\n website: item.loc._text,\n description: item.loc._text,\n runtime: item.lenghtmin._text,\n popularity: Number(item.views._text || 0),\n isFree: 1,\n }\n }\n\n _normalizeItem(item) {\n if (item._source === 'moviePage') {\n item = this._normalizePageItem(item)\n } else {\n item = this._normalizeApiItem(item)\n }\n\n return super._normalizeItem(item)\n }\n\n _normalizeStream(stream) {\n let quality = stream.url.match(/-(\\d+)p/i)\n\n return super._normalizeStream({\n id: stream.id,\n url: stream.url,\n title: quality ? quality[1] : 'Watch',\n availability: 1,\n live: true,\n isFree: true,\n })\n }\n\n _makeApiUrl(query, skip, limit) {\n let { search, genre } = query\n let keywords\n\n if (search && genre) {\n keywords = `${genre},${search}`\n } else {\n keywords = search || genre || 'all'\n }\n\n keywords = keywords.replace(' ', '+')\n return `${BASE_URL}/api_xml/${keywords}/${limit}/${skip}/adddate`\n }\n\n _makeMovieUrl(id) {\n return `${BASE_URL}/hd-porn/${id}`\n }\n\n _makeVideoDownloadUrl(path) {\n return BASE_URL + path\n }\n\n _parseApiResponse(xml) {\n let results = xml2js(xml, {\n compact: true,\n trim: true,\n })['eporner-data'].movie\n\n if (!results) {\n return []\n } else if (!Array.isArray(results)) {\n return [results]\n } else {\n return results\n }\n }\n\n _parseMoviePage(body) {\n let $ = cheerio.load(body)\n let title = $('meta[property=\"og:title\"]')\n .attr('content')\n .replace(/(\\s*-\\s*)?EPORNER/i, '')\n let description = $('meta[property=\"og:description\"]').attr('content')\n let duration = description.match(/duration:\\s*((:?\\d)+)/i)[1]\n let url = $('meta[property=\"og:url\"]').attr('content')\n let image = $('meta[property=\"og:image\"]').attr('content')\n let tags = $('#hd-porn-tags td')\n .filter((i, item) => $(item).text().trim() === 'Tags:')\n .next()\n .find('a')\n .map((i, item) => $(item).text().trim())\n .toArray()\n let downloadUrls = $('#hd-porn-dload a')\n .map((i, link) => {\n let href = $(link).attr('href')\n return this._makeVideoDownloadUrl(href)\n })\n .toArray()\n\n return {\n _source: 'moviePage',\n title, url, image, tags, duration, downloadUrls,\n }\n }\n\n async _find(query, { skip, limit }) {\n let url = this._makeApiUrl(query, skip, limit)\n let { body } = await this.httpClient.request(url)\n return this._parseApiResponse(body)\n }\n\n async _getItem(type, id) {\n let url = this._makeMovieUrl(id)\n let { body } = await this.httpClient.request(url)\n return this._parseMoviePage(body)\n }\n\n async _getStreams(type, id) {\n // Video downloads are restricted to 30 per day per guest\n\n let url = this._makeMovieUrl(id)\n let { body } = await this.httpClient.request(url)\n let { downloadUrls } = this._parseMoviePage(body)\n\n let streamUrls = downloadUrls.map((url) => {\n return this.httpClient.request(url, { followRedirect: false })\n })\n streamUrls = await Promise.all(streamUrls)\n\n return streamUrls\n .map((res) => {\n return { id, url: res.headers.location }\n })\n .filter((stream) => stream.url)\n }\n}\n\n\nexport default EPorner\n"],"file":"EPorner.js"} -------------------------------------------------------------------------------- /tests/adapters/YouPorn/embeddedMoviePage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | MIA KHALIFA - Big Tits Arab Pornstar Takes A Fan's Virginity 7 | 8 | 9 | 13 | 14 | 29 | 30 | 31 | 32 | 44 | 53 | 54 | 55 | 56 |
57 | 60 |
61 |
MIA KHALIFA - Big Tits Arab Pornstar Takes A Fan's Virginity 62 | 210 | 211 | 212 | 213 | --------------------------------------------------------------------------------