",
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