├── .babelrc
├── .eslintrc.js
├── .gitignore
├── .npmingnore
├── .nycrc
├── .prettierignore
├── .prettierrc
├── .prettierrc.json
├── .sequelizerc
├── README.md
├── client
├── .gitignore
├── README.md
├── package-lock.json
├── package.json
├── public
│ ├── icons
│ │ ├── facebook_cover_photo_1.png
│ │ ├── facebook_cover_photo_2.png
│ │ ├── facebook_profile_image.png
│ │ ├── favicon.png
│ │ ├── instagram_profile_image.png
│ │ ├── linkedin_banner_image_1.png
│ │ ├── linkedin_banner_image_2.png
│ │ ├── linkedin_profile_image.png
│ │ ├── logo.png
│ │ ├── logo_transparent.png
│ │ ├── pinterest_board_photo.png
│ │ ├── pinterest_profile_image.png
│ │ ├── twitter_header_photo_1.png
│ │ ├── twitter_header_photo_2.png
│ │ ├── twitter_profile_image.png
│ │ └── youtube_profile_image.png
│ ├── index.html
│ └── manifest.json
├── src
│ ├── actions
│ │ └── index.js
│ ├── components
│ │ ├── Admin.js
│ │ ├── App.js
│ │ ├── Header.js
│ │ ├── Hero.js
│ │ ├── HeroSimple.js
│ │ ├── InfoModal.js
│ │ ├── MediaCard.js
│ │ ├── MediaList.js
│ │ ├── MediaModal.js
│ │ ├── NavBar.js
│ │ ├── PopularCard.js
│ │ ├── PopularList.js
│ │ ├── PrivateRoute.js
│ │ ├── SimilarCard.js
│ │ ├── SimilarList.js
│ │ ├── TopRatedCard.js
│ │ ├── TopRatedList.js
│ │ ├── auth
│ │ │ ├── Login.js
│ │ │ └── SignUp.js
│ │ ├── buttons
│ │ │ ├── Auth.js
│ │ │ └── TvList.js
│ │ ├── helpers
│ │ │ └── Header.js
│ │ └── plex
│ │ │ ├── ImportPlexLibrary.js
│ │ │ ├── Plex.js
│ │ │ ├── PlexPin.js
│ │ │ └── PlexTokenForm.js
│ ├── css
│ │ ├── materialize.css
│ │ └── materialize.css.js
│ ├── index.js
│ ├── reducers
│ │ ├── authReducer.js
│ │ ├── index.js
│ │ ├── plexReducer.js
│ │ └── sonarrReducer.js
│ ├── serviceWorker.js
│ └── setupProxy.js
└── yarn.lock
├── config
├── auth.js
├── index.js
├── local.js
├── plex.js
├── production.js
├── tdaw.js
├── test.js
└── winston.js
├── index.js
├── package-lock.json
├── package.json
├── restore-db.sh
├── server
├── controllers
│ ├── admin.controller.js
│ ├── auth.controller.js
│ ├── movieDb.controller.js
│ ├── plex.controller.js
│ ├── recommend.controller.js
│ ├── sonarr.controller.js
│ └── tdaw.controller.js
├── db
│ ├── config
│ │ └── config.json
│ ├── migrations
│ │ ├── 20190224043920-create-user.js
│ │ ├── 20190224045315-create-plex-library.js
│ │ ├── 20190224045418-create-plex-section.js
│ │ ├── 20190901211623-add-user-library-association.js
│ │ ├── 20190901212012-add-user-section-association.js
│ │ ├── 20190903014701-add_admin_to_user.js
│ │ └── 20190905024038-add_password_to_user_model.js
│ ├── models
│ │ ├── index.js
│ │ ├── plexSection.js
│ │ ├── plexlibrary.js
│ │ └── user.js
│ ├── scripts
│ │ └── index.js
│ └── seeders
│ │ └── index.js
├── index.js
├── initialize.js
├── routes
│ ├── admin.route.js
│ ├── auth.route.js
│ ├── movieDb.route.js
│ ├── plex.route.js
│ ├── recommend.route.js
│ ├── sonarr.route.js
│ └── tdaw.route.js
└── services
│ ├── auth
│ └── passport.js
│ ├── helpers.js
│ ├── moviedb
│ ├── index.js
│ └── movieDbApi.js
│ ├── plex
│ ├── auth.js
│ ├── importData.js
│ ├── index.js
│ └── plexApi.js
│ ├── recommend
│ └── index.js
│ ├── sonarr
│ ├── index.js
│ └── sonarrApi.js
│ └── tdaw
│ ├── index.js
│ └── tdawApi.js
├── static.json
└── test
├── .eslintrc.json
├── mocks
├── authResponse.xml
├── error.html
├── getUsersResponse.xml
├── plexPinResponse.xml
├── plexResponses.js
└── tdawResponses.js
├── nocks.js
└── server
├── controllers
├── auth.controller.test.js
└── plex.controller.test.js
├── helpers.js
└── services
├── .DS_Store
├── plex
├── .DS_Store
├── auth.test.js
├── importData.test.js
├── index.test.js
└── plexApi.test.js
└── tdaw
└── tdawApi.test.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env"
4 | ],
5 | "plugins": [
6 | "istanbul",
7 | [
8 | "@babel/plugin-transform-runtime",
9 | {
10 | "regenerator": true
11 | }
12 | ]
13 | ]
14 | }
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['airbnb-base', 'prettier'],
3 | env: {
4 | node: true,
5 | mocha: true,
6 | },
7 | plugins: ['prefer-arrow'],
8 | rules: {
9 | 'no-console': 0,
10 | 'import/no-dynamic-require': 0,
11 | 'func-names': 0,
12 | 'prefer-arrow-callback': 0,
13 | 'no-unused-expressions': 0,
14 | 'no-shadow': 0,
15 | 'no-use-before-define': 0,
16 | 'prefer-arrow/prefer-arrow-functions': [
17 | 0,
18 | {
19 | disallowPrototype: true,
20 | singleReturnOnly: false,
21 | classPropertiesAllowed: false,
22 | },
23 | ],
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | node_modules
3 | .vscode
4 | .nyc_output
5 | dist
6 | npm-debug.log
7 | coverage
8 | .nycrc
9 | .npmignore
10 | .eslinstrc.js
11 | .DS_Store
12 | .prettierignore
13 | *.log
14 | .prettierrc.json
15 | .sequelizerc
16 | *.dump
--------------------------------------------------------------------------------
/.npmingnore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | .DS_Store
4 | .nyc_output
5 | bab.cache
6 | test
7 | src
8 | coverage
9 |
--------------------------------------------------------------------------------
/.nycrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@istanbuljs/nyc-config-babel"
3 | }
4 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist/
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "trailingComma": "all",
4 | "singleQuote": true,
5 | "printWidth": 70,
6 | }
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "bracketSpacing": false,
4 | "jsxBracketSameLine": false,
5 | "printWidth": 80,
6 | "proseWrap": "preserve",
7 | "requirePragma": false,
8 | "semi": true,
9 | "singleQuote": true,
10 | "tabWidth": 2,
11 | "trailingComma": "all",
12 | "useTabs": false,
13 | "overrides": [
14 | {
15 | "files": "*.json",
16 | "options": {
17 | "printWidth": 200
18 | }
19 | }
20 | ]
21 | }
--------------------------------------------------------------------------------
/.sequelizerc:
--------------------------------------------------------------------------------
1 | var path = require('path')
2 |
3 | module.exports = {
4 | config: path.resolve('server', 'db', 'config', 'config.json'),
5 | 'models-path': path.resolve('server', 'db', 'models'),
6 | 'seeders-path': path.resolve('server', 'db', 'seeders'),
7 | 'migrations-path': path.resolve('server', 'db', 'migrations')
8 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # What2Watch2Night
2 |
3 | [What2Watch2Night](https://what2watch2night.herokuapp.com/) is a free open source application to help Plex users find new movies and TV shows. Movies and TV shows are recommended to you based on your current Plex watching habits.
4 |
5 | [View Demo](https://streamable.com/ghkbb)
6 |
7 | _This is still in development. If you would like to help contribute feel free to check out any of the open issues._
8 |
9 | ## Setup and install
10 |
11 | These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system
12 |
13 | ## Installing
14 | Clone the repository
15 |
16 | `git clone git@github.com:mjrode/WhatToWatch.git`
17 |
18 | cd into the new directory
19 |
20 | `cd WhatToWatch`
21 |
22 | ## Server (run from the root directory)
23 |
24 | Install dependencies
25 |
26 | `npm install`
27 |
28 | Install npx to easily run migrations
29 | `npm install -g npx`
30 |
31 |
32 | Create database, test database, and run the migrations
33 | `npx sequelize db:create`
34 | `NODE_ENV=test npx sequelize db:create`
35 | `npm run db:reset`
36 |
37 | ## Prerequisites
38 | * **Required Keys**
39 | #### Rename `example.env` to `.env` and update the test tokens with your tokens
40 |
41 |
42 | [Google OAuth 2.0](https://developers.google.com/identity/protocols/OAuth2)
43 | * `GOOGLE_CLIENT_ID`
44 | * `GOOGLE_CLIENT_SECRET`
45 |
46 | [cookie-session](https://www.npmjs.com/package/cookie-session)
47 | * `COOKIE_KEY`
48 |
49 | [Express Port (optional)](https://expressjs.com)
50 | * `PORT` _defaults to 8080 if no value is provided_
51 |
52 | [The Movie DB](https://developers.themoviedb.org/3/getting-started)
53 | * `MOVIE_API_KEY`
54 |
55 | [TasteDive](https://tastedive.com/read/api)
56 | * `TDAW_API_TOKEN`
57 |
58 | ## Running the tests
59 | `npm test`
60 |
61 | # Client (run from WhatToWatch/client)
62 | ## install dependencies
63 | `npm install`
64 |
65 | ## Run application
66 | Concurrently run the frontend and backend servers
67 |
68 | `npm run dev`
69 |
70 | ## Built With
71 | * [Node](www.example.com)
72 | * [React](www.example.com)
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
2 |
3 | ## Available Scripts
4 |
5 | In the project directory, you can run:
6 |
7 | ### `npm start`
8 |
9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
11 |
12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console.
14 |
15 | ### `npm test`
16 |
17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
19 |
20 | ### `npm run build`
21 |
22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance.
24 |
25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed!
27 |
28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
29 |
30 | ### `npm run eject`
31 |
32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
33 |
34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
35 |
36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
37 |
38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
39 |
40 | ## Learn More
41 |
42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
43 |
44 | To learn React, check out the [React documentation](https://reactjs.org/).
45 |
46 | ### Code Splitting
47 |
48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
49 |
50 | ### Analyzing the Bundle Size
51 |
52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
53 |
54 | ### Making a Progressive Web App
55 |
56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
57 |
58 | ### Advanced Configuration
59 |
60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
61 |
62 | ### Deployment
63 |
64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
65 |
66 | ### `npm run build` fails to minify
67 |
68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
69 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@material-ui/core": "^3.9.3",
7 | "@material-ui/icons": "^3.0.2",
8 | "axios": "^0.18.0",
9 | "http-proxy-middleware": "^0.19.1",
10 | "materialize-css": "^1.0.0",
11 | "react": "^16.8.6",
12 | "react-dom": "^16.8.6",
13 | "react-ga": "^2.5.7",
14 | "react-redux": "^6.0.1",
15 | "react-router-dom": "^5.0.0",
16 | "react-scripts": "2.1.8",
17 | "react-toastify": "^5.0.1",
18 | "redux": "^4.0.1",
19 | "redux-form": "^8.1.0",
20 | "redux-thunk": "^2.3.0"
21 | },
22 | "scripts": {
23 | "start": "react-scripts start",
24 | "build": "react-scripts build",
25 | "test": "react-scripts test",
26 | "eject": "react-scripts eject"
27 | },
28 | "eslintConfig": {
29 | "extends": "react-app"
30 | },
31 | "browserslist": [
32 | ">0.2%",
33 | "not dead",
34 | "not ie <= 11",
35 | "not op_mini all"
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/client/public/icons/facebook_cover_photo_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/facebook_cover_photo_1.png
--------------------------------------------------------------------------------
/client/public/icons/facebook_cover_photo_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/facebook_cover_photo_2.png
--------------------------------------------------------------------------------
/client/public/icons/facebook_profile_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/facebook_profile_image.png
--------------------------------------------------------------------------------
/client/public/icons/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/favicon.png
--------------------------------------------------------------------------------
/client/public/icons/instagram_profile_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/instagram_profile_image.png
--------------------------------------------------------------------------------
/client/public/icons/linkedin_banner_image_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/linkedin_banner_image_1.png
--------------------------------------------------------------------------------
/client/public/icons/linkedin_banner_image_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/linkedin_banner_image_2.png
--------------------------------------------------------------------------------
/client/public/icons/linkedin_profile_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/linkedin_profile_image.png
--------------------------------------------------------------------------------
/client/public/icons/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/logo.png
--------------------------------------------------------------------------------
/client/public/icons/logo_transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/logo_transparent.png
--------------------------------------------------------------------------------
/client/public/icons/pinterest_board_photo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/pinterest_board_photo.png
--------------------------------------------------------------------------------
/client/public/icons/pinterest_profile_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/pinterest_profile_image.png
--------------------------------------------------------------------------------
/client/public/icons/twitter_header_photo_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/twitter_header_photo_1.png
--------------------------------------------------------------------------------
/client/public/icons/twitter_header_photo_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/twitter_header_photo_2.png
--------------------------------------------------------------------------------
/client/public/icons/twitter_profile_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/twitter_profile_image.png
--------------------------------------------------------------------------------
/client/public/icons/youtube_profile_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/client/public/icons/youtube_profile_image.png
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
15 |
16 |
20 |
21 |
25 |
26 |
35 | WhatToWatch
36 |
37 |
38 |
39 |
40 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/actions/index.js:
--------------------------------------------------------------------------------
1 | import { toast } from 'react-toastify';
2 | import axios from 'axios';
3 | export const types = {
4 | SET_LOADING: 'set_loading',
5 | FETCH_USER: 'fetch_user',
6 | SIGN_UP_USER: 'sign_up_user',
7 | FETCH_USERS: 'fetch_users',
8 | FETCH_MEDIA_RESPONSE: 'fetch_media_response',
9 | GET_MOST_WATCHED: 'get_most_watched',
10 | ADD_SERIES: 'add_series',
11 | CURRENT_SHOW: 'current_show',
12 | FETCH_PIN: 'fetch_pin',
13 | CHECK_PLEX_PIN: 'check_plex_pin',
14 | };
15 |
16 | export const setLoading = loading => dispatch => {
17 | dispatch({
18 | type: types.SET_LOADING,
19 | payload: { loading: loading },
20 | });
21 | };
22 |
23 | export const fetchUser = () => async dispatch => {
24 | const res = await axios.get('/api/auth/current_user');
25 | dispatch({ type: types.FETCH_USER, payload: res.data });
26 | };
27 |
28 | export const signUpUser = params => async dispatch => {
29 | const res = await axios({
30 | method: 'post',
31 | url: '/api/auth/sign-up',
32 | data: params,
33 | });
34 | console.log('signupres', res);
35 | dispatch({ type: types.SIGN_UP_USER, payload: res.data });
36 | };
37 |
38 | export const fetchUsers = () => async dispatch => {
39 | const res = await axios.get('/api/admin/users');
40 | dispatch({ type: types.FETCH_USERS, payload: res.data });
41 | };
42 |
43 | export const fetchPin = () => async dispatch => {
44 | const res = await axios.get('/api/plex/plex-pin');
45 | dispatch({ type: types.FETCH_PIN, payload: res.data });
46 | };
47 |
48 | export const fetchMedia = () => async dispatch => {
49 | dispatch({ type: types.SET_LOADING, payload: true });
50 | const res = await axios.get('/api/plex/import/all');
51 | console.log('fetchMedia', res);
52 | dispatch({ type: types.SET_LOADING, payload: false });
53 | dispatch({ type: types.FETCH_MEDIA_RESPONSE, payload: res.data });
54 | };
55 |
56 | export const getMostWatched = params => async dispatch => {
57 | dispatch({ type: types.SET_LOADING, payload: true });
58 | const res = await axios.get('/api/recommend/most-watched');
59 | console.log('TCL: res', res);
60 |
61 | dispatch({ type: types.SET_LOADING, payload: false });
62 | dispatch({ type: types.GET_MOST_WATCHED, payload: res.data });
63 | };
64 |
65 | export const addSeries = params => async dispatch => {
66 | dispatch({ type: types.SET_LOADING, payload: true });
67 | dispatch({ type: types.CURRENT_SHOW, payload: params.showName });
68 | const res = await axios.get('/api/sonarr/series/add', { params });
69 | dispatch({ type: types.SET_LOADING, payload: false });
70 | res.data.title
71 | ? toast('Successfully added: ' + res.data.title)
72 | : toast(res.data);
73 | dispatch({ type: types.ADD_SERIES, payload: res.data });
74 | };
75 |
76 | const createPoller = (interval, initialDelay) => {
77 | let timeoutId = null;
78 | let poller = () => {};
79 | return fn => {
80 | window.clearTimeout(timeoutId);
81 | poller = () => {
82 | timeoutId = window.setTimeout(poller, 1000);
83 | return fn();
84 | };
85 | if (initialDelay) {
86 | return (timeoutId = window.setTimeout(poller, 1000));
87 | }
88 | return poller();
89 | };
90 | };
91 |
92 | export const createPollingAction = (
93 | action,
94 | interval,
95 | initialDelay,
96 | ) => {
97 | const poll = createPoller(action, initialDelay);
98 | return () => (dispatch, getState) =>
99 | poll(() => action(dispatch, getState));
100 | };
101 |
102 | export const checkPlexPin = createPollingAction(dispatch => {
103 | axios.get('/api/plex/check-plex-pin').then(res => {
104 | if (res.data) {
105 | var highestTimeoutId = setTimeout(';');
106 | console.log('highestTimeoutId', highestTimeoutId)
107 | for (var i = 0; i < highestTimeoutId; i++) {
108 | clearTimeout(i);
109 | }
110 | }
111 | console.log('action res', res);
112 | dispatch({ type: types.CHECK_PLEX_PIN, payload: res.data });
113 | });
114 | }, 15000);
115 |
--------------------------------------------------------------------------------
/client/src/components/Admin.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import axios from 'axios';
4 | import { Link, Redirect } from 'react-router-dom';
5 | import HeroSimple from './HeroSimple';
6 | import * as actions from './../actions';
7 | import Typography from '@material-ui/core/Typography';
8 |
9 | class Admin extends Component {
10 | async componentDidMount() {
11 | await this.props.fetchUser();
12 | await this.props.fetchUsers();
13 | console.log('users--', this.props.auth.users);
14 | console.log('user--', this.props.auth.user);
15 | }
16 |
17 | loginAsUser = async userForSignIn => {
18 | console.log('I was clicked and got a user', userForSignIn);
19 |
20 | console.log('params', userForSignIn);
21 | const res = await axios({
22 | method: 'post',
23 | url: '/api/auth/fake-session',
24 | data: {
25 | email: userForSignIn.email,
26 | password: this.props.auth.admin,
27 | },
28 | });
29 | console.log('login response', res.data);
30 | };
31 |
32 | renderUsers() {
33 | if (this.props.auth.users) {
34 | const usersList = this.props.auth.users.map(user => {
35 | return (
36 |
44 |
50 |
51 | );
52 | });
53 | return {usersList}
;
54 | }
55 | }
56 | render() {
57 | if (!this.props) {
58 | return;
59 | }
60 |
61 | if (!this.props.auth.admin) {
62 | console.log('checking for admin rights', this.props.auth);
63 | return (
64 |
65 |
Checking for admin rights..
66 |
67 | );
68 | }
69 | console.log(this.props.auth);
70 | return (
71 |
72 |
73 |
74 |
{this.renderUsers()}
75 |
76 | );
77 | }
78 | }
79 |
80 | function mapStateToProps({ auth }) {
81 | console.log('plex props', auth);
82 | return { auth };
83 | }
84 |
85 | export default connect(
86 | mapStateToProps,
87 | actions,
88 | )(Admin);
89 |
--------------------------------------------------------------------------------
/client/src/components/App.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {BrowserRouter, Route} from 'react-router-dom';
3 | import {connect} from 'react-redux';
4 | import * as actions from '../actions';
5 |
6 | import Header from './Header';
7 | import Hero from './Hero';
8 | import Plex from './plex/Plex';
9 | import Admin from './Admin';
10 | import PlexPin from './plex/PlexPin';
11 | import SimilarList from './SimilarList';
12 | import PopularList from './PopularList';
13 | import PlexTokenForm from './plex/PlexTokenForm';
14 | import TopRatedList from './TopRatedList';
15 | import SignUp from './auth/SignUp';
16 | import Login from './auth/Login';
17 |
18 | class App extends Component {
19 | componentDidMount() {
20 | this.props.fetchUser();
21 | }
22 |
23 | render() {
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | }
40 | />
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 | }
50 |
51 | export default connect(
52 | null,
53 | actions,
54 | )(App);
55 |
--------------------------------------------------------------------------------
/client/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { Link } from 'react-router-dom';
4 | import '../css/materialize.css';
5 | class Header extends Component {
6 | renderContent() {
7 | const isMobile = window.innerWidth < 480;
8 | switch (this.props.auth) {
9 | case null:
10 | return;
11 | case false:
12 | return (
13 |
14 |
15 | Login / Register
16 |
17 |
18 | );
19 | default:
20 | if (!isMobile) {
21 | return (
22 |
23 |
24 | Most Watched
25 |
26 |
27 | Popular
28 |
29 |
30 | Logout
31 |
32 |
33 | );
34 | }
35 | }
36 | }
37 | render() {
38 | return (
39 |
51 | );
52 | }
53 | }
54 |
55 | function mapStateToProps({ auth }) {
56 | return { auth };
57 | }
58 |
59 | export default connect(mapStateToProps)(Header);
60 |
--------------------------------------------------------------------------------
/client/src/components/Hero.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import CssBaseline from '@material-ui/core/CssBaseline';
4 | import Typography from '@material-ui/core/Typography';
5 | import { withStyles } from '@material-ui/core/styles';
6 | import { connect } from 'react-redux';
7 | import styles from '../css/materialize.css';
8 | import TvListButtons from './buttons/TvList';
9 | import AuthButtons from './buttons/Auth';
10 | import '../css/materialize.css';
11 |
12 | class Hero extends Component {
13 | callToAction = () => {
14 | if (this.props.auth.email) {
15 | return ;
16 | }
17 | return ;
18 | };
19 |
20 | render() {
21 | const { classes } = this.props;
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
36 |
44 |
45 |
51 | Media recommendations based on your most watched Plex
52 | TV.
53 |
54 |
55 | {this.callToAction()}
56 |
57 |
58 |
59 |
60 |
61 | );
62 | }
63 | }
64 |
65 | Hero.propTypes = {
66 | classes: PropTypes.object.isRequired,
67 | };
68 |
69 | function mapStateToProps({ auth }) {
70 | return { auth };
71 | }
72 |
73 | export default connect(mapStateToProps)(withStyles(styles)(Hero));
74 |
--------------------------------------------------------------------------------
/client/src/components/HeroSimple.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import PropTypes from 'prop-types';
3 | import CssBaseline from '@material-ui/core/CssBaseline';
4 | import Typography from '@material-ui/core/Typography';
5 | import {withStyles} from '@material-ui/core/styles';
6 | import {connect} from 'react-redux';
7 | import styles from '../css/materialize.css';
8 | import '../css/materialize.css';
9 |
10 | class HeroSimple extends Component {
11 | render() {
12 | const {classes} = this.props;
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
27 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 | }
42 |
43 | HeroSimple.propTypes = {
44 | classes: PropTypes.object.isRequired,
45 | };
46 |
47 | function mapStateToProps({auth}) {
48 | return {auth};
49 | }
50 |
51 | export default connect(mapStateToProps)(withStyles(styles)(HeroSimple));
52 |
--------------------------------------------------------------------------------
/client/src/components/InfoModal.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {withStyles} from '@material-ui/core/styles';
3 | import Button from '@material-ui/core/Button';
4 | import Dialog from '@material-ui/core/Dialog';
5 | import MuiDialogTitle from '@material-ui/core/DialogTitle';
6 | import MuiDialogContent from '@material-ui/core/DialogContent';
7 | import MuiDialogActions from '@material-ui/core/DialogActions';
8 | import IconButton from '@material-ui/core/IconButton';
9 | import CloseIcon from '@material-ui/icons/Close';
10 | import Typography from '@material-ui/core/Typography';
11 |
12 | const DialogTitle = withStyles(theme => ({
13 | root: {
14 | borderBottom: `1px solid ${theme.palette.divider}`,
15 | margin: 0,
16 | padding: theme.spacing.unit * 2,
17 | },
18 | closeButton: {
19 | position: 'absolute',
20 | right: theme.spacing.unit,
21 | top: theme.spacing.unit,
22 | color: theme.palette.grey[500],
23 | },
24 | }))(props => {
25 | const {children, classes, onClose} = props;
26 | return (
27 |
28 | {children}
29 | {onClose ? (
30 |
35 |
36 |
37 | ) : null}
38 |
39 | );
40 | });
41 |
42 | const DialogContent = withStyles(theme => ({
43 | root: {
44 | margin: 0,
45 | padding: theme.spacing.unit * 2,
46 | },
47 | }))(MuiDialogContent);
48 |
49 | const DialogActions = withStyles(theme => ({
50 | root: {
51 | borderTop: `1px solid ${theme.palette.divider}`,
52 | margin: 0,
53 | padding: theme.spacing.unit,
54 | },
55 | }))(MuiDialogActions);
56 |
57 | class CustomizedDialogDemo extends React.Component {
58 | state = {
59 | open: false,
60 | };
61 |
62 | handleClickOpen = () => {
63 | this.setState({
64 | open: true,
65 | });
66 | };
67 |
68 | handleClose = () => {
69 | this.setState({open: false});
70 | };
71 |
72 | render() {
73 | return (
74 |
75 |
83 |
106 |
107 | );
108 | }
109 | }
110 |
111 | export default CustomizedDialogDemo;
112 |
--------------------------------------------------------------------------------
/client/src/components/MediaCard.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import PropTypes from 'prop-types';
3 | import {withStyles} from '@material-ui/core/styles';
4 | import {connect} from 'react-redux';
5 | import Header from './helpers/Header';
6 | import styles from '../css/materialize.css';
7 | import '../css/materialize.css';
8 | import {Link} from 'react-router-dom';
9 |
10 | class MediaCard extends Component {
11 | render() {
12 | const show = this.props.media;
13 | const isMobile = window.innerWidth < 480;
14 | if (!isMobile) {
15 | return (
16 |
17 |
18 |
19 |
20 |
27 |

32 |
33 |
34 |
35 |
36 |
37 |
38 |
{show.summary}
39 |
40 |
41 |
46 | live_tvSimilar
47 | Shows
48 |
49 |
{show.views} Views
50 |
51 |
52 |
53 |
54 |
55 |
56 | );
57 | }
58 | return (
59 |
60 |
61 |
62 |
63 |

68 |
69 |
72 |
73 |
78 | live_tvSimilar Shows
79 |
80 |
81 |
82 |
83 |
84 | );
85 | }
86 | }
87 |
88 | MediaCard.propTypes = {
89 | classes: PropTypes.object.isRequired,
90 | };
91 |
92 | function mapStateToProps({auth}) {
93 | return {auth};
94 | }
95 |
96 | export default connect(mapStateToProps)(withStyles(styles)(MediaCard));
97 |
--------------------------------------------------------------------------------
/client/src/components/MediaList.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import PropTypes from 'prop-types';
3 | import {withStyles} from '@material-ui/core/styles';
4 | import {connect} from 'react-redux';
5 | import styles from '../css/materialize.css.js';
6 | import MediaCard from './MediaCard';
7 | import * as actions from '../actions';
8 |
9 | class MediaList extends Component {
10 | componentDidMount() {
11 | this.props.getMostWatched();
12 | }
13 |
14 | render() {
15 | if (this.props.tvShowList) {
16 | const mediaList = this.props.tvShowList.map(show => {
17 | return (
18 |
19 |
20 |
21 | );
22 | });
23 | return {mediaList}
;
24 | }
25 |
26 | return (
27 |
30 | );
31 | }
32 | }
33 |
34 | MediaList.propTypes = {
35 | classes: PropTypes.object.isRequired,
36 | };
37 |
38 | function mapStateToProps(state) {
39 | return {
40 | loading: state.plex.loading,
41 | plexToken: state.plex.plexToken,
42 | tvShowList: state.plex.tvShowList,
43 | mediaResponse: state.plex.mediaResponse,
44 | };
45 | }
46 |
47 | export default connect(
48 | mapStateToProps,
49 | actions,
50 | )(withStyles(styles)(MediaList));
51 |
--------------------------------------------------------------------------------
/client/src/components/MediaModal.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {withStyles} from '@material-ui/core/styles';
3 | import Button from '@material-ui/core/Button';
4 | import Dialog from '@material-ui/core/Dialog';
5 | import MuiDialogTitle from '@material-ui/core/DialogTitle';
6 | import MuiDialogContent from '@material-ui/core/DialogContent';
7 | import MuiDialogActions from '@material-ui/core/DialogActions';
8 | import IconButton from '@material-ui/core/IconButton';
9 | import CloseIcon from '@material-ui/icons/Close';
10 | import Typography from '@material-ui/core/Typography';
11 |
12 | const DialogTitle = withStyles(theme => ({
13 | root: {
14 | borderBottom: `1px solid ${theme.palette.divider}`,
15 | margin: 0,
16 | padding: theme.spacing.unit * 2,
17 | },
18 | closeButton: {
19 | position: 'absolute',
20 | right: theme.spacing.unit,
21 | top: theme.spacing.unit,
22 | color: theme.palette.grey[500],
23 | },
24 | }))(props => {
25 | const {children, classes, onClose} = props;
26 | return (
27 |
28 | {children}
29 | {onClose ? (
30 |
35 |
36 |
37 | ) : null}
38 |
39 | );
40 | });
41 |
42 | const DialogContent = withStyles(theme => ({
43 | root: {
44 | margin: 0,
45 | padding: theme.spacing.unit * 2,
46 | },
47 | }))(MuiDialogContent);
48 |
49 | const DialogActions = withStyles(theme => ({
50 | root: {
51 | borderTop: `1px solid ${theme.palette.divider}`,
52 | margin: 0,
53 | padding: theme.spacing.unit,
54 | },
55 | }))(MuiDialogActions);
56 |
57 | class CustomizedDialogDemo extends React.Component {
58 | state = {
59 | open: false,
60 | };
61 |
62 | handleClickOpen = () => {
63 | this.setState({
64 | open: true,
65 | });
66 | };
67 |
68 | handleClose = () => {
69 | this.setState({open: false});
70 | };
71 |
72 | render() {
73 | return (
74 |
75 |
83 |
106 |
107 | );
108 | }
109 | }
110 |
111 | export default CustomizedDialogDemo;
112 |
--------------------------------------------------------------------------------
/client/src/components/NavBar.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import AppBar from '@material-ui/core/AppBar';
3 | import Toolbar from '@material-ui/core/Toolbar';
4 |
5 | const NavBar = () => {
6 | return (
7 |
8 |
9 | React Material UI Example
10 |
11 |
12 | );
13 | };
14 | export default NavBar;
15 |
--------------------------------------------------------------------------------
/client/src/components/PopularCard.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import PropTypes from 'prop-types';
3 | import {withStyles} from '@material-ui/core/styles';
4 | import {connect} from 'react-redux';
5 | import Header from './helpers/Header';
6 | import styles from '../css/materialize.css';
7 | import {ToastContainer} from 'react-toastify';
8 | import * as actions from '../actions';
9 | import {Link} from 'react-router-dom';
10 | import 'react-toastify/dist/ReactToastify.css';
11 |
12 | class PopularCard extends Component {
13 | renderContent() {
14 | if (
15 | this.props.loading &&
16 | this.props.currentShow === this.props.media.name
17 | ) {
18 | return (
19 |
22 | );
23 | }
24 | }
25 | renderToast() {
26 | if (this.props.currentShow === this.props.media.name) {
27 | return ;
28 | }
29 | }
30 | renderButton(show) {
31 | if (this.props.sonarrApiKey && this.props.sonarrUrl) {
32 | return (
33 |
44 | );
45 | }
46 | return (
47 |
48 | sendLink Sonarr
49 |
50 | );
51 | }
52 |
53 | render() {
54 | const show = this.props.media;
55 | const isMobile = window.innerWidth < 480;
56 | if (!isMobile) {
57 | return (
58 |
59 | {this.renderToast()}
60 | {this.renderContent()}
61 |
62 |
63 |
64 |
71 |

76 |
77 |
78 |
79 |
80 |
81 |
82 |
{show.overview}
83 |
84 |
85 |
86 | Rating: {` ${show.vote_average} `}| Popularity:{' '}
87 | {` ${show.popularity}`}
88 |
89 |
90 | {this.renderButton(show)}
91 |
92 |
93 |
94 |
95 |
96 |
97 | );
98 | }
99 | return (
100 |
101 |
102 |
103 |
104 |
111 |

116 |
117 |
118 |
{show.overview}
119 |
120 |
121 | {this.renderButton(show)}
122 |
123 |
124 |
125 |
126 | );
127 | }
128 | }
129 |
130 | PopularCard.propTypes = {
131 | classes: PropTypes.object.isRequired,
132 | };
133 |
134 | function mapStateToProps(state) {
135 | return {
136 | loading: state.sonarr.loading,
137 | sonarrAddSeries: state.sonarr.sonarrAddSeries,
138 | currentShow: state.plex.currentShow,
139 | };
140 | }
141 |
142 | export default connect(
143 | mapStateToProps,
144 | actions,
145 | )(withStyles(styles)(PopularCard));
146 |
--------------------------------------------------------------------------------
/client/src/components/PopularList.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {Redirect} from 'react-router-dom';
3 | import {withStyles} from '@material-ui/core/styles';
4 | import {connect} from 'react-redux';
5 | import styles from '../css/materialize.css.js';
6 | import axios from 'axios';
7 | import PopularCard from './PopularCard';
8 | import * as actions from '../actions';
9 |
10 | class PopularList extends Component {
11 | state = {
12 | shows: [],
13 | };
14 | componentDidMount() {
15 | this.getSimilar();
16 | }
17 |
18 | getSimilar = async () => {
19 | const res = await axios.get('/api/moviedb/tv/popular');
20 | const shows = res.data;
21 | this.setState({shows: shows});
22 | };
23 |
24 | render() {
25 | if (!this.props.auth) {
26 | return ;
27 | }
28 | if (this.state.shows.length > 0) {
29 | const mediaList = this.state.shows.map((show, index) => {
30 | return ;
31 | });
32 | return {mediaList}
;
33 | }
34 |
35 | return (
36 |
39 | );
40 | }
41 | }
42 |
43 | function mapStateToProps({plex, auth, sonarr}) {
44 | return {loading: plex.loading, auth, sonarrAddSeries: sonarr.sonarrAddSeries};
45 | }
46 |
47 | export default connect(
48 | mapStateToProps,
49 | actions,
50 | )(withStyles(styles)(PopularList));
51 |
--------------------------------------------------------------------------------
/client/src/components/PrivateRoute.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Redirect, Route} from 'react-router-dom';
3 | import {connect} from 'react-redux';
4 | // Utils
5 |
6 | const PrivateRoute = ({component: Component, ...rest}) => (
7 |
10 | props.auth !== null ? (
11 |
12 | ) : (
13 |
19 | )
20 | }
21 | />
22 | );
23 |
24 | export default PrivateRoute;
25 |
--------------------------------------------------------------------------------
/client/src/components/SimilarCard.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import PropTypes from 'prop-types';
3 | import {withStyles} from '@material-ui/core/styles';
4 | import {connect} from 'react-redux';
5 | import Header from './helpers/Header';
6 | import styles from '../css/materialize.css';
7 | import {ToastContainer} from 'react-toastify';
8 | import * as actions from '../actions';
9 | import 'react-toastify/dist/ReactToastify.css';
10 | import {Link} from 'react-router-dom';
11 |
12 | class MediaCard extends Component {
13 | renderContent() {
14 | if (
15 | this.props.loading &&
16 | this.props.currentShow === this.props.media.name
17 | ) {
18 | return (
19 |
22 | );
23 | }
24 | }
25 | renderToast() {
26 | if (this.props.currentShow === this.props.media.name) {
27 | return ;
28 | }
29 | }
30 | renderButton(show) {
31 | if (this.props.sonarrApiKey && this.props.sonarrUrl) {
32 | return (
33 |
44 | );
45 | }
46 | return (
47 |
48 | sendLink Sonarr
49 |
50 | );
51 | }
52 | render() {
53 | const show = this.props.media;
54 | const isMobile = window.innerWidth < 480;
55 | if (!isMobile) {
56 | return (
57 |
58 | {this.renderToast()}
59 | {this.renderContent()}
60 |
61 |
62 |
63 |
70 |

75 |
76 |
77 |
78 |
79 |
80 |
81 |
{show.overview}
82 |
83 |
84 |
85 | Rating: {` ${show.vote_average} `}| Popularity:{' '}
86 | {` ${show.popularity}`}
87 |
88 |
89 | {this.renderButton(show)}
90 |
91 |
92 |
93 |
94 |
95 |
96 | );
97 | }
98 | return (
99 |
100 |
101 |
102 |
103 |
110 |

115 |
116 |
117 |
{show.overview}
118 |
119 |
120 | this.props.addSeries({showName: show.name})}
126 | >
127 | sendAdd to Sonarr
128 |
129 |
130 |
131 |
132 |
133 | );
134 | }
135 | }
136 |
137 | MediaCard.propTypes = {
138 | classes: PropTypes.object.isRequired,
139 | };
140 |
141 | function mapStateToProps(state) {
142 | return {
143 | loading: state.sonarr.loading,
144 | sonarrAddSeries: state.sonarr.sonarrAddSeries,
145 | currentShow: state.plex.currentShow,
146 | sonarrUrl: state.auth.sonarrUrl,
147 | sonarrApiKey: state.auth.sonarrApiKey,
148 | };
149 | }
150 |
151 | export default connect(
152 | mapStateToProps,
153 | actions,
154 | )(withStyles(styles)(MediaCard));
155 |
--------------------------------------------------------------------------------
/client/src/components/SimilarList.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Redirect } from 'react-router-dom';
3 | import { withStyles } from '@material-ui/core/styles';
4 | import { connect } from 'react-redux';
5 | import styles from '../css/materialize.css.js';
6 | import axios from 'axios';
7 | import SimilarCard from './SimilarCard';
8 | import * as actions from '../actions';
9 | class Similar extends Component {
10 | state = {
11 | shows: [],
12 | };
13 | componentDidMount() {
14 | console.log('Similar list mounted');
15 | this.getSimilar();
16 | }
17 |
18 | getSimilar = async () => {
19 | const params = { showName: this.props.match.params.show };
20 | console.log('params---', params);
21 | const res = await axios.get('/api/moviedb/tv/similar', {
22 | params,
23 | });
24 | console.log('Shows--', shows);
25 | const shows = res.data;
26 | this.setState({ shows: shows });
27 | };
28 |
29 | render() {
30 | if (!this.props.auth) {
31 | return ;
32 | }
33 | if (this.state.shows.length > 0) {
34 | const mediaList = this.state.shows.map((show, index) => {
35 | return ;
36 | });
37 | return {mediaList}
;
38 | }
39 |
40 | return (
41 |
44 | );
45 | }
46 | }
47 |
48 | function mapStateToProps({ plex, auth, sonarr }) {
49 | return {
50 | loading: plex.loading,
51 | auth,
52 | sonarrAddSeries: sonarr.sonarrAddSeries,
53 | };
54 | }
55 |
56 | export default connect(
57 | mapStateToProps,
58 | actions,
59 | )(withStyles(styles)(Similar));
60 |
--------------------------------------------------------------------------------
/client/src/components/TopRatedCard.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import PropTypes from 'prop-types';
3 | import {withStyles} from '@material-ui/core/styles';
4 | import {connect} from 'react-redux';
5 | import Header from './helpers/Header';
6 | import styles from '../css/materialize.css';
7 | import {ToastContainer} from 'react-toastify';
8 | import * as actions from '../actions';
9 | import {Link} from 'react-router-dom';
10 | import 'react-toastify/dist/ReactToastify.css';
11 |
12 | class TopRatedCard extends Component {
13 | renderContent() {
14 | if (
15 | this.props.loading &&
16 | this.props.currentShow === this.props.media.name
17 | ) {
18 | return (
19 |
22 | );
23 | }
24 | }
25 | renderToast() {
26 | if (this.props.currentShow === this.props.media.name) {
27 | return ;
28 | }
29 | }
30 |
31 | render() {
32 | const show = this.props.media;
33 | const isMobile = window.innerWidth < 480;
34 | if (!isMobile) {
35 | return (
36 |
37 | {this.renderToast()}
38 | {this.renderContent()}
39 |
40 |
41 |
42 |
49 |

54 |
55 |
56 |
57 |
58 |
59 |
60 |
{show.overview}
61 |
62 |
63 |
64 | Rating: {` ${show.vote_average} `}| Popularity:{' '}
65 | {` ${show.popularity}`}
66 |
67 |
68 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | );
88 | }
89 | return (
90 |
91 |
92 |
93 |
94 |
101 |

106 |
107 |
108 |
{show.overview}
109 |
110 |
111 | this.props.addSeries({showName: show.name})}
117 | >
118 | sendAdd to Sonarr
119 |
120 |
121 |
122 |
123 |
124 | );
125 | }
126 | }
127 |
128 | TopRatedCard.propTypes = {
129 | classes: PropTypes.object.isRequired,
130 | };
131 |
132 | function mapStateToProps(state) {
133 | return {
134 | loading: state.sonarr.loading,
135 | sonarrAddSeries: state.sonarr.sonarrAddSeries,
136 | currentShow: state.plex.currentShow,
137 | };
138 | }
139 |
140 | export default connect(
141 | mapStateToProps,
142 | actions,
143 | )(withStyles(styles)(TopRatedCard));
144 |
--------------------------------------------------------------------------------
/client/src/components/TopRatedList.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {Redirect} from 'react-router-dom';
3 | import {withStyles} from '@material-ui/core/styles';
4 | import {connect} from 'react-redux';
5 | import styles from '../css/materialize.css.js';
6 | import axios from 'axios';
7 | import PopularCard from './PopularCard';
8 | import * as actions from '../actions';
9 |
10 | class TopRatedList extends Component {
11 | state = {
12 | shows: [],
13 | };
14 | componentDidMount() {
15 | this.getSimilar();
16 | }
17 |
18 | getSimilar = async () => {
19 | const res = await axios.get('/api/moviedb/tv/top-rated');
20 | const shows = res.data;
21 | this.setState({shows: shows});
22 | };
23 |
24 | render() {
25 | if (!this.props.auth) {
26 | return ;
27 | }
28 | if (this.state.shows.length > 0) {
29 | const mediaList = this.state.shows.map((show, index) => {
30 | return ;
31 | });
32 | return {mediaList}
;
33 | }
34 |
35 | return (
36 |
39 | );
40 | }
41 | }
42 |
43 | function mapStateToProps({plex, auth, sonarr}) {
44 | return {loading: plex.loading, auth, sonarrAddSeries: sonarr.sonarrAddSeries};
45 | }
46 |
47 | export default connect(
48 | mapStateToProps,
49 | actions,
50 | )(withStyles(styles)(TopRatedList));
51 |
--------------------------------------------------------------------------------
/client/src/components/auth/Login.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import axios from 'axios';
3 | import {Redirect} from 'react-router-dom';
4 | import PropTypes from 'prop-types';
5 | import CssBaseline from '@material-ui/core/CssBaseline';
6 | import {connect} from 'react-redux';
7 | import {withStyles} from '@material-ui/core/styles';
8 | import '../../css/materialize.css';
9 | import TextHeader from '../helpers/Header';
10 | import styles from '../../css/materialize.css';
11 |
12 | class Login extends React.Component {
13 | state = {
14 | email: '',
15 | password: '',
16 | };
17 |
18 | onFormSubmit = event => {
19 | event.preventDefault();
20 | console.log('loginuserstate', this.state);
21 | this.loginUser(this.state);
22 | };
23 |
24 | loginUser = async params => {
25 | console.log('params', params);
26 | const res = await axios({
27 | method: 'post',
28 | url: '/api/auth/login',
29 | data: params,
30 | });
31 | console.log('login response', res.data);
32 | };
33 |
34 | render() {
35 | const {classes} = this.props;
36 | if (this.props.auth) {
37 | return ;
38 | } else if (!this.props.auth) {
39 | return (
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
{this.state.errorMessage}
51 |
52 |
53 |
101 |
102 |
103 |
104 | );
105 | }
106 | }
107 | }
108 |
109 | Login.propTypes = {
110 | classes: PropTypes.object.isRequired,
111 | };
112 |
113 | function mapStateToProps({auth}) {
114 | return {auth};
115 | }
116 |
117 | export default connect(mapStateToProps)(withStyles(styles)(Login));
118 |
--------------------------------------------------------------------------------
/client/src/components/auth/SignUp.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import axios from 'axios';
3 | import { Redirect } from 'react-router-dom';
4 | import * as actions from './../../actions';
5 | import PropTypes from 'prop-types';
6 | import CssBaseline from '@material-ui/core/CssBaseline';
7 | import { connect } from 'react-redux';
8 | import { withStyles } from '@material-ui/core/styles';
9 | import '../../css/materialize.css';
10 | import TextHeader from '../helpers/Header';
11 | import styles from '../../css/materialize.css';
12 |
13 | class SignUp extends React.Component {
14 | state = {
15 | email: '',
16 | password: '',
17 | };
18 |
19 | componentWilldMount() {
20 | const res = this.props.fetchUser();
21 | console.log('user', res);
22 | }
23 |
24 | onFormSubmit = async event => {
25 | event.preventDefault();
26 | console.log('signUpUserState', this.state);
27 | const res = await this.props.signUpUser(this.state);
28 | console.log('res', this.props.auth);
29 | console.log('res', this.props.auth);
30 | };
31 |
32 | render() {
33 | const { classes } = this.props;
34 | if (this.props.auth) {
35 | return ;
36 | } else if (!this.props.auth) {
37 | return (
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | {this.state.errorMessage}
50 |
51 |
52 |
53 |
110 |
111 |
112 |
113 | );
114 | }
115 | }
116 | }
117 |
118 | SignUp.propTypes = {
119 | classes: PropTypes.object.isRequired,
120 | };
121 |
122 | function mapStateToProps({ auth }) {
123 | return { auth };
124 | }
125 |
126 | export default connect(
127 | mapStateToProps,
128 | actions,
129 | )(withStyles(styles)(SignUp));
130 |
--------------------------------------------------------------------------------
/client/src/components/buttons/Auth.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | const Auth = () => {
5 | return (
6 |
7 |
15 |
16 |
20 | show_chartLogin
21 |
22 |
23 |
24 |
28 | show_chartSignUp
29 |
30 |
31 |
32 | );
33 | };
34 | export default Auth;
35 |
--------------------------------------------------------------------------------
/client/src/components/buttons/TvList.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | const TvListButtons = () => {
5 | return (
6 |
7 |
8 |
12 | live_tv
13 | Most Watched
14 |
15 |
16 |
17 |
18 |
22 | show_chartPopular TV
23 |
24 |
25 |
26 |
30 | star_borderTop Rated
31 | TV
32 |
33 |
34 |
35 | );
36 | };
37 | export default TvListButtons;
38 |
--------------------------------------------------------------------------------
/client/src/components/helpers/Header.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import Typography from '@material-ui/core/Typography';
3 | import PropTypes from 'prop-types';
4 | import {withStyles} from '@material-ui/core/styles';
5 | import styles from '../../css/materialize.css';
6 |
7 | class Header extends Component {
8 | render() {
9 | return (
10 |
17 | {this.props.text}
18 |
19 | );
20 | }
21 | }
22 |
23 | Header.propTypes = {
24 | classes: PropTypes.object.isRequired,
25 | };
26 |
27 | export default withStyles(styles)(Header);
28 |
--------------------------------------------------------------------------------
/client/src/components/plex/ImportPlexLibrary.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {withStyles} from '@material-ui/core/styles';
3 | import {connect} from 'react-redux';
4 | import styles from '../../css/materialize.css.js';
5 | import '../../css/materialize.css';
6 | import * as actions from '../../actions';
7 |
8 | class ImportPlexLibrary extends Component {
9 | componentWillMount() {
10 | this.props.fetchMedia();
11 | }
12 | render() {
13 | return (
14 |
19 | );
20 | }
21 | }
22 |
23 | function mapStateToProps({auth}) {
24 | return {auth};
25 | }
26 |
27 | export default connect(
28 | mapStateToProps,
29 | actions,
30 | )(withStyles(styles)(ImportPlexLibrary));
31 |
--------------------------------------------------------------------------------
/client/src/components/plex/Plex.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {connect} from 'react-redux';
3 | import PlexTokenForm from './PlexTokenForm';
4 | import {Link} from 'react-router-dom';
5 | import ImportPlexLibrary from './ImportPlexLibrary';
6 | import MediaList from '../MediaList';
7 |
8 | class Plex extends Component {
9 | render() {
10 | if (!this.props) {
11 | return;
12 | }
13 | if (!this.props.auth.plexToken) {
14 | return (
15 |
18 | );
19 | }
20 | return (
21 |
22 |
23 |
24 |
25 |
29 | sendGet Started
30 |
31 |
32 |
33 | );
34 | }
35 | }
36 |
37 | function mapStateToProps({auth, plex}) {
38 | console.log('plex props', auth)
39 | return {auth, mediaResponse: plex.mediaResponse};
40 | }
41 |
42 | export default connect(mapStateToProps)(Plex);
43 |
--------------------------------------------------------------------------------
/client/src/components/plex/PlexPin.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { Link, Redirect } from 'react-router-dom';
4 | import HeroSimple from '../HeroSimple';
5 | import * as actions from '../../actions';
6 | import Typography from '@material-ui/core/Typography';
7 |
8 | class PlexPin extends Component {
9 | async componentDidMount() {
10 | await this.props.fetchPin();
11 | await this.props.checkPlexPin();
12 | }
13 | render() {
14 | if (!this.props) {
15 | return;
16 | }
17 | if (this.props.auth.plexToken) {
18 | console.log('plex pinn', this.props.auth);
19 | return ;
20 | }
21 | if (!this.props.auth.plexToken) {
22 | console.log('mike--', this.props.auth);
23 | return (
24 |
25 |
26 |
27 |
41 |
42 |
49 | {this.props.auth.plexPin}
50 |
51 |
52 |
53 | );
54 | }
55 | return (
56 |
57 |
58 |
62 | sendGet Started
63 |
64 |
65 |
66 | );
67 | }
68 | }
69 |
70 | function mapStateToProps({ auth }) {
71 | console.log('auth state to prop', auth);
72 | return { auth };
73 | }
74 |
75 | export default connect(
76 | mapStateToProps,
77 | actions,
78 | )(PlexPin);
79 |
--------------------------------------------------------------------------------
/client/src/components/plex/PlexTokenForm.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import axios from 'axios';
3 | import {Redirect} from 'react-router-dom';
4 | import PropTypes from 'prop-types';
5 | import CssBaseline from '@material-ui/core/CssBaseline';
6 | import {connect} from 'react-redux';
7 | import {withStyles} from '@material-ui/core/styles';
8 | import '../../css/materialize.css';
9 | import TextHeader from '../helpers/Header';
10 | import styles from '../../css/materialize.css';
11 |
12 | class PlexTokenForm extends React.Component {
13 | state = {
14 | email: '',
15 | password: '',
16 | sonarrUrl: '',
17 | sonarrApiKey: '',
18 | errorMessage: '',
19 | redirect: false,
20 | };
21 |
22 | onFormSubmit = event => {
23 | event.preventDefault();
24 | this.getPlexToken(this.state);
25 | };
26 |
27 | getPlexToken = async params => {
28 | const res = await axios.get('/api/plex/token', {params});
29 | console.log('response', res.data);
30 | if (res.data.includes('Invalid')) {
31 | this.setState({errorMessage: res.data});
32 | } else {
33 | window.location.reload();
34 | }
35 | };
36 |
37 | render() {
38 | const {classes} = this.props;
39 | if (this.props.auth.sonarrUrl && this.props.auth.sonarrApiKey) {
40 | return ;
41 | }
42 | if (!this.props.auth) {
43 | return ;
44 | } else if (this.props.auth) {
45 | return (
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
{this.state.errorMessage}
57 |
58 |
59 |
109 |
110 |
111 |
112 | );
113 | }
114 | }
115 | }
116 |
117 | PlexTokenForm.propTypes = {
118 | classes: PropTypes.object.isRequired,
119 | };
120 |
121 | function mapStateToProps({auth}) {
122 | return {auth};
123 | }
124 |
125 | export default connect(mapStateToProps)(withStyles(styles)(PlexTokenForm));
126 |
--------------------------------------------------------------------------------
/client/src/css/materialize.css:
--------------------------------------------------------------------------------
1 | .no-bottom-margin {
2 | margin-bottom: 0px;
3 | }
4 | .min-button-width {
5 | min-width: 15em;
6 | }
7 | .margin-spacing {
8 | margin: 3em 0;
9 | }
10 |
11 | .padding-bottom-5 {
12 | padding-bottom: 5em;
13 | }
14 |
15 | .margin-left {
16 | margin-left: 3em;
17 | }
18 |
19 | .abs {
20 | position: absolute;
21 | }
22 |
23 | .flex-center {
24 | display: flex;
25 | justify-content: center;
26 | }
27 |
28 | .code {
29 | padding: 1rem;
30 | border-radius: 1rem;
31 | }
32 |
33 | .margin-bottom-button {
34 | margin-bottom: 2em
35 | }
36 |
37 | .hide-mobile {
38 | @media (max-width: 480px) {
39 | display: none;
40 | }
41 | }
42 |
43 | .hide-desktop {
44 | @media not all and (max-width: 480px) {
45 | display: none;
46 | }
47 | }
48 |
49 | .Button:hover {
50 | background-color: rgb(98, 87, 113);
51 | }
52 |
53 | .large-card-height{
54 | height: auto !important;
55 | }
56 |
57 |
58 | .robots{
59 | font-family: "Roboto", "Helvetica", "Arial", sans-serif
60 | }
--------------------------------------------------------------------------------
/client/src/css/materialize.css.js:
--------------------------------------------------------------------------------
1 | const styles = theme => ({
2 | appBar: {
3 | position: 'relative',
4 | },
5 |
6 | icon: {
7 | marginRight: theme.spacing.unit * 2,
8 | },
9 | heroUnit: {
10 | backgroundColor: theme.palette.background.paper,
11 | },
12 | heroContent: {
13 | maxWidth: 600,
14 | margin: '0 auto',
15 | padding: `${theme.spacing.unit * 8}px 0 ${theme.spacing.unit * 6}px`,
16 | },
17 | heroContentSmall: {
18 | maxWidth: 600,
19 | margin: '0 auto',
20 | padding: `${theme.spacing.unit * 2}px 0 ${theme.spacing.unit * 1}px`,
21 | },
22 | heroButtons: {
23 | marginTop: theme.spacing.unit * 4,
24 | },
25 | layout: {
26 | width: 'auto',
27 | marginLeft: theme.spacing.unit * 3,
28 | marginRight: theme.spacing.unit * 3,
29 | [theme.breakpoints.up(1100 + theme.spacing.unit * 3 * 2)]: {
30 | width: 1100,
31 | marginLeft: 'auto',
32 | marginRight: 'auto',
33 | },
34 | },
35 | cardGrid: {
36 | padding: `${theme.spacing.unit * 8}px 0`,
37 | },
38 | card: {
39 | height: '100%',
40 | display: 'flex',
41 | flexDirection: 'column',
42 | },
43 | cardMedia: {
44 | paddingTop: '56.25%', // 16:9
45 | },
46 | cardContent: {
47 | flexGrow: 1,
48 | },
49 | footer: {
50 | backgroundColor: theme.palette.background.paper,
51 | padding: theme.spacing.unit * 6,
52 | },
53 | shrinkTopMargin: {
54 | margin: `${theme.spacing.unit}px ${theme.spacing.unit}px ${
55 | theme.spacing.unit
56 | }px ${theme.spacing.unit}px`,
57 | },
58 | });
59 |
60 | export default styles;
61 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import 'materialize-css/dist/css/materialize.min.css';
2 | import React from 'react';
3 | import ReactDOM from 'react-dom';
4 | import {Provider} from 'react-redux';
5 | import {createStore, applyMiddleware} from 'redux';
6 | import reduxThunk from 'redux-thunk';
7 | import axios from 'axios';
8 |
9 |
10 | import App from './components/App';
11 | import reducers from './reducers';
12 |
13 | window.axios = axios;
14 |
15 | const store = createStore(reducers, {}, applyMiddleware(reduxThunk));
16 |
17 | ReactDOM.render(
18 |
19 |
20 | ,
21 | document.querySelector('#root'),
22 | );
23 |
--------------------------------------------------------------------------------
/client/src/reducers/authReducer.js:
--------------------------------------------------------------------------------
1 | import { types } from '../actions/index';
2 |
3 | export const initialState = {
4 | loading: false,
5 | plexPin: '',
6 | user: '',
7 | users: '',
8 | };
9 |
10 | export default function(state = {}, action) {
11 | console.log('action - type', action.type);
12 | console.log('action - type', action.payload);
13 | switch (action.type) {
14 | case types.FETCH_USER:
15 | return action.payload || false;
16 | case types.SIGN_UP_USER:
17 | return action.payload || false;
18 | case types.FETCH_USERS:
19 | return { ...state, users: action.payload };
20 | case types.FETCH_PIN:
21 | return { ...state, plexPin: action.payload };
22 | case types.CHECK_PLEX_PIN:
23 | return { ...state, plexToken: action.payload };
24 | default:
25 | return state;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/client/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import {combineReducers} from 'redux';
2 | import authReducer from './authReducer';
3 | import plexReducer from './plexReducer';
4 | import sonarrReducer from './sonarrReducer';
5 |
6 | export default combineReducers({
7 | auth: authReducer,
8 | plex: plexReducer,
9 | sonarr: sonarrReducer,
10 | });
11 |
--------------------------------------------------------------------------------
/client/src/reducers/plexReducer.js:
--------------------------------------------------------------------------------
1 | import {types} from '../actions/index';
2 |
3 | export const initialState = {
4 | loading: false,
5 | plexToken: '',
6 | tvShowList: [],
7 | mediaResponse: '',
8 | currentShow: '',
9 | };
10 | export default function(state = initialState, action) {
11 | switch (action.type) {
12 | case types.FETCH_PLEX_TOKEN:
13 | return {...state, plexToken: action.payload};
14 | case types.FETCH_MEDIA_RESPONSE:
15 | const newState = {...state, mediaResponse: action.payload};
16 | return newState;
17 | case types.SET_LOADING:
18 | return {...state, loading: action.payload};
19 | case types.GET_MOST_WATCHED:
20 | return {...state, tvShowList: [...action.payload]};
21 | case types.CURRENT_SHOW:
22 | return {...state, currentShow: action.payload};
23 | default:
24 | return state;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/reducers/sonarrReducer.js:
--------------------------------------------------------------------------------
1 | import {types} from '../actions/index';
2 |
3 | export const initialState = {
4 | loading: false,
5 | addSeries: '',
6 | };
7 | export default function(state = initialState, action) {
8 | switch (action.type) {
9 | case types.ADD_SERIES:
10 | return {...state, sonarrAddSeries: action.payload};
11 | case types.SET_LOADING:
12 | return {...state, loading: action.payload};
13 | default:
14 | return state;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.1/8 is considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl)
104 | .then(response => {
105 | // Ensure service worker exists, and that we really are getting a JS file.
106 | const contentType = response.headers.get('content-type');
107 | if (
108 | response.status === 404 ||
109 | (contentType != null && contentType.indexOf('javascript') === -1)
110 | ) {
111 | // No service worker found. Probably a different app. Reload the page.
112 | navigator.serviceWorker.ready.then(registration => {
113 | registration.unregister().then(() => {
114 | window.location.reload();
115 | });
116 | });
117 | } else {
118 | // Service worker found. Proceed as normal.
119 | registerValidSW(swUrl, config);
120 | }
121 | })
122 | .catch(() => {
123 | console.log(
124 | 'No internet connection found. App is running in offline mode.'
125 | );
126 | });
127 | }
128 |
129 | export function unregister() {
130 | if ('serviceWorker' in navigator) {
131 | navigator.serviceWorker.ready.then(registration => {
132 | registration.unregister();
133 | });
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/client/src/setupProxy.js:
--------------------------------------------------------------------------------
1 | const proxy = require('http-proxy-middleware');
2 |
3 | module.exports = function(server) {
4 | server.use(
5 | proxy(['/api', '/auth/google', '/auth', '/auth/fake-session'], {
6 | target: 'http://localhost:8080',
7 | }),
8 | );
9 | };
10 |
--------------------------------------------------------------------------------
/config/auth.js:
--------------------------------------------------------------------------------
1 | const authConfig = {
2 | tdawApiUrl: 'https://tastedive.com/api/similar',
3 | token: process.env.TDAW_API_TOKEN,
4 | };
5 | export default authConfig;
6 |
--------------------------------------------------------------------------------
/config/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | require('custom-env').env();
3 | require('dotenv').config();
4 | const _ = require('lodash');
5 |
6 | const env = process.env.NODE_ENV || 'local';
7 |
8 | const envConfig = require(`./${env}`).default;
9 | const plexConfig = require('./plex').default;
10 | const tdawConfig = require('./tdaw').default;
11 |
12 | const defaultConfig = {
13 | env,
14 | };
15 | export default {
16 | server: _.merge(defaultConfig, envConfig),
17 | plex: plexConfig,
18 | tdaw: tdawConfig,
19 | };
20 |
--------------------------------------------------------------------------------
/config/local.js:
--------------------------------------------------------------------------------
1 | const localConfig = {
2 | hostname: 'localhost',
3 | port: 8080,
4 | googleClientID: process.env.GOOGLE_CLIENT_ID,
5 | googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
6 | cookieKey: process.env.COOKIE_KEY,
7 | movieApiKey: process.env.MOVIE_API_KEY,
8 | logToConsole: true,
9 | };
10 |
11 | export default localConfig;
12 |
--------------------------------------------------------------------------------
/config/plex.js:
--------------------------------------------------------------------------------
1 | const plexConfig = {
2 | ip: 'http://192.168.0.44',
3 | plexUrl: process.env.PLEX_URL,
4 | plexApiUrl: 'https://plex.tv/api',
5 | token: process.env.PLEX_API_TOKEN,
6 | };
7 | export default plexConfig;
8 |
--------------------------------------------------------------------------------
/config/production.js:
--------------------------------------------------------------------------------
1 | const prodConfig = {
2 | googleClientID: process.env.GOOGLE_CLIENT_ID,
3 | googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
4 | cookieKey: process.env.COOKIE_KEY,
5 | port: process.env.PORT,
6 | movieApiKey: process.env.MOVIE_API_KEY,
7 | };
8 |
9 | export default prodConfig;
10 |
--------------------------------------------------------------------------------
/config/tdaw.js:
--------------------------------------------------------------------------------
1 | const tdawConfig = {
2 | tdawApiUrl: 'https://tastedive.com/api/similar',
3 | token: process.env.TDAW_API_TOKEN,
4 | };
5 | export default tdawConfig;
6 |
--------------------------------------------------------------------------------
/config/test.js:
--------------------------------------------------------------------------------
1 | const testConfig = {
2 | hostname: 'localhost',
3 | port: 8080,
4 | googleClientID: process.env.GOOGLE_CLIENT_ID,
5 | googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
6 | cookieKey: process.env.COOKIE_KEY,
7 | movieApiKey: process.env.MOVIE_API_KEY,
8 | logToConsole: true,
9 | };
10 |
11 | export default testConfig;
12 |
--------------------------------------------------------------------------------
/config/winston.js:
--------------------------------------------------------------------------------
1 | var appRoot = require('app-root-path');
2 | import { inspect } from 'util';
3 | import path from 'path';
4 | const { createLogger, format, transports } = require('winston');
5 | const {
6 | combine,
7 | timestamp,
8 | label,
9 | prettyPrint,
10 | colorize,
11 | splat,
12 | errors,
13 | simple,
14 | } = format;
15 |
16 | const prettyJson = format.printf(info => {
17 | if (info.message.constructor === Object) {
18 | info.message = inspect(info.message, {
19 | depth: 3,
20 | colors: true,
21 | });
22 | }
23 | return `WINSTON: ${info.timestamp} ${info.level}: ${info.message}`;
24 | });
25 |
26 | const logger = createLogger({
27 | level: 'info',
28 | format: combine(
29 | colorize(),
30 | prettyPrint(),
31 | errors({ stack: true }),
32 | splat(),
33 | label(),
34 | timestamp(),
35 | simple(),
36 | prettyJson,
37 | ),
38 | transports: [
39 | new transports.File({ filename: `${appRoot}/logs/app.log` }),
40 | new transports.Console(),
41 | ],
42 | exitOnError: false, // do not exit on handled exceptions
43 | });
44 |
45 | logger.stream = {
46 | write: function(message, encoding) {
47 | logger.info(message, { level: 'HTTP' });
48 | },
49 | };
50 |
51 | module.exports = logger;
52 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import config from './config';
2 |
3 | const server = require('./server').default();
4 |
5 | server.create(config);
6 | server.start();
7 |
8 | export default server.create(config);
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "recommend",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "engines": {
7 | "node": "10.15.0",
8 | "npm": "6.4.1"
9 | },
10 | "scripts": {
11 | "test": "cross-env NODE_ENV=test PLEX_API_TOKEN=testPlexApiToken TDAW_API_TOKEN=testTdawToken nyc --reporter=html --reporter=text mocha --require @babel/register 'test/**/*.test.js' --exit",
12 | "debug:test": "cross-env NODE_ENV=test PLEX_API_TOKEN=testPlexApiToken TDAW_API_TOKEN=testTdawToken nyc --reporter=html --reporter=text mocha --inspect-brk --require @babel/register 'test/**/*.test.js' --exit",
13 | "db:create": "npm run db:create && npm run db:migrate && NODE_ENV=test npm run db:create && npm run db:migrate",
14 | "db:create:test": "NODE_ENV=test npx sequelize db:create",
15 | "db:create-migration": "babel-node ./scripts/db/createMigration",
16 | "db:drop": "babel-node ./scripts/db/drop",
17 | "db:migrate": "babel-node ./scripts/db/migrate",
18 | "db:rollback": "babel-node ./scripts/db/rollback",
19 | "dev": "concurrently \"npm run server\" \"npm run client\"",
20 | "debug": "nodemon --exec babel-node --inspect index.js",
21 | "db:reset": "NODE_ENV=test npx sequelize db:migrate:undo:all && NODE_ENV=test npx sequelize db:migrate && npx sequelize db:migrate:undo:all && npx sequelize db:migrate",
22 | "prod:dump": "rm latest.dump && heroku pg:backups:capture && heroku pg:backups:download && pg_restore --verbose --clean --no-acl --no-owner -h localhost -U postgres -d recommend_development latest.dump",
23 | "start": "babel-node index.js",
24 | "server": "nodemon --exec babel-node index.js",
25 | "client": "npm run start --prefix client",
26 | "heroku-postbuild": "npm install && NPM_CONFIG_PRODUCTION=false npm install --prefix client && npm run build --prefix client"
27 | },
28 | "nyc": {
29 | "reporter": [
30 | "lcov",
31 | "text"
32 | ],
33 | "sourceMap": false,
34 | "instrument": false
35 | },
36 | "author": "",
37 | "license": "ISC",
38 | "dependencies": {
39 | "@babel/core": "^7.0.0",
40 | "@babel/node": "^7.5.5",
41 | "@babel/plugin-transform-runtime": "^7.5.5",
42 | "@babel/preset-env": "^7.5.5",
43 | "@babel/register": "^7.5.5",
44 | "@babel/runtime": "^7.5.5",
45 | "app-root-path": "^2.2.1",
46 | "axios": "^0.19.0",
47 | "babel-plugin-istanbul": "^5.2.0",
48 | "babel-preset-node8": "^1.2.0",
49 | "bcrypt-nodejs": "0.0.3",
50 | "bluebird": "^3.5.5",
51 | "body-parser": "^1.19.0",
52 | "btoa": "^1.2.1",
53 | "build-url": "^1.3.3",
54 | "child-process-promise": "^2.2.1",
55 | "concurrently": "^4.1.2",
56 | "connect-flash": "^0.1.1",
57 | "cookie-parser": "^1.4.4",
58 | "cookie-session": "^1.3.3",
59 | "cors": "^2.8.5",
60 | "cross-env": "^5.2.1",
61 | "custom-env": "^1.0.2",
62 | "dotenv": "^8.1.0",
63 | "express": "^4.17.1",
64 | "http-proxy-middleware": "^0.19.1",
65 | "lodash": "^4.17.15",
66 | "morgan": "^1.9.1",
67 | "morgan-body": "^2.4.7",
68 | "moviedb-promise": "^1.4.1",
69 | "onchange": "^6.0.0",
70 | "passport": "^0.4.0",
71 | "passport-google-oauth20": "^2.0.0",
72 | "passport-http": "^0.3.0",
73 | "passport-local": "^1.0.0",
74 | "passport-mocked": "^1.4.0",
75 | "pg": "^7.12.1",
76 | "pg-hstore": "^2.3.3",
77 | "plex-api": "^5.2.5",
78 | "request": "^2.88.0",
79 | "request-promise": "^4.2.4",
80 | "sequelize": "^5.18.0",
81 | "sequelize-cli": "^5.5.1",
82 | "sinon": "^7.4.1",
83 | "string-similarity": "^3.0.0",
84 | "tdaw": "^1.3.0",
85 | "uuid": "^3.3.3",
86 | "whatwg-url": "^7.0.0",
87 | "winston": "^3.2.1",
88 | "winston-daily-rotate-file": "^4.0.0",
89 | "xml2json": "^0.11.2",
90 | "yarn": "^1.17.3"
91 | },
92 | "devDependencies": {
93 | "chai": "^4.2.0",
94 | "chai-http": "^4.3.0",
95 | "faker": "^4.1.0",
96 | "husky": "3.0.5",
97 | "i": "^0.3.6",
98 | "lint-staged": "9.2.5",
99 | "mocha": "^6.2.0",
100 | "nock": "^10.0.6",
101 | "nodemon": "^1.19.2",
102 | "npm": "^6.11.2",
103 | "nyc": "^14.1.1",
104 | "prettier": "1.18.2",
105 | "sequelize-test-helpers": "^1.1.2",
106 | "supertest": "^4.0.2",
107 | "timekeeper": "^2.2.0"
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/restore-db.sh:
--------------------------------------------------------------------------------
1 | pg_restore --verbose --clean --no-acl --no-owner -h localhost -U postgres -d recommend_development latest.dump
2 |
3 |
--------------------------------------------------------------------------------
/server/controllers/admin.controller.js:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import models from '../db/models';
3 | const router = Router();
4 |
5 | router.get('/users', async (req, res) => {
6 | const users = await models.User.findAll();
7 | const filteredUsers = users.filter(user => user.plexToken);
8 |
9 | res.send(filteredUsers);
10 | });
11 |
12 | router.get('/login-as-user', async (req, res) => {
13 | res.send(users);
14 | });
15 |
16 | export default router;
17 |
--------------------------------------------------------------------------------
/server/controllers/auth.controller.js:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import passport from 'passport';
3 |
4 | const router = Router();
5 |
6 | router.get(
7 | '/google',
8 | passport.authenticate('google', {
9 | scope: ['profile', 'email'],
10 | }),
11 | );
12 |
13 | router.post('/sign-up', function(req, res, next) {
14 | passport.authenticate('local-signup', function(err, user, info) {
15 | if (err) {
16 | return next(err);
17 | }
18 | if (!user) {
19 | return res.json({ message: info.message });
20 | }
21 | req.login(user, function(err) {
22 | if (err) {
23 | console.log(err);
24 | }
25 | res.send({ email: req.user.email });
26 | });
27 | })(req, res, next);
28 | });
29 | router.post('/fake-session', (req, res, next) => {
30 | passport.authenticate('fake-session', function(err, user, info) {
31 | if (err) {
32 | return next(err);
33 | }
34 | if (!user) {
35 | return res.json({ message: info.message });
36 | }
37 | req.login(user, function(err) {
38 | if (err) {
39 | console.log(err);
40 | }
41 | res.send({ email: req.user.email });
42 | });
43 | })(req, res, next);
44 | });
45 | router.post('/login', function(req, res, next) {
46 | passport.authenticate('local-login', function(err, user, info) {
47 | if (err) {
48 | return next(err);
49 | }
50 | if (!user) {
51 | return res.json({ message: info.message });
52 | }
53 | req.login(user, function(err) {
54 | if (err) {
55 | console.log(err);
56 | }
57 | res.send({ email: req.user.email });
58 | });
59 | })(req, res, next);
60 | });
61 |
62 | router.get(
63 | '/google/callback',
64 | passport.authenticate('google'),
65 | function(req, res) {
66 | res.redirect('/plex-pin' + `?email=${req.user.email}`);
67 | },
68 | );
69 |
70 | router.get('/current_user', (req, res) => {
71 | res.send(req.user);
72 | });
73 |
74 | router.get('/logout', (req, res) => {
75 | req.logout();
76 | res.redirect('/');
77 | });
78 |
79 | export default router;
80 |
--------------------------------------------------------------------------------
/server/controllers/movieDb.controller.js:
--------------------------------------------------------------------------------
1 | import {Router} from 'express';
2 | import movieDbService from '../services/moviedb';
3 | import tdawService from '../services/tdaw';
4 |
5 | const router = Router();
6 |
7 | router.get('/tv/search', movieDbService.searchTv);
8 | // router.get('/tv/similar', movieDbService.similarTv);
9 | router.get('/tv/similar', tdawService.similarMedia);
10 | router.get('/tv/popular', movieDbService.popularTv);
11 | router.get('/tv/top-rated', movieDbService.topRatedTv);
12 |
13 | export default router;
14 |
--------------------------------------------------------------------------------
/server/controllers/plex.controller.js:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import plexService from '../services/plex';
3 |
4 | const router = Router();
5 |
6 | router.get('/plex-pin', plexService.getPlexPin);
7 | router.get('/check-plex-pin', plexService.checkPlexPin);
8 |
9 | router.get('/users', plexService.getUsers);
10 |
11 | router.get('/most-watched', plexService.getMostWatched);
12 | router.get('/import/most-watched', plexService.importMostWatched);
13 |
14 | router.get('/sections', plexService.getSections);
15 | router.get('/import/sections', plexService.importSections);
16 |
17 | router.get('/library/:id', plexService.getLibraryDataBySection);
18 | router.get('/import/libraries', plexService.importLibraries);
19 |
20 | router.get('/import/all', plexService.importAll);
21 |
22 | export default router;
23 |
--------------------------------------------------------------------------------
/server/controllers/recommend.controller.js:
--------------------------------------------------------------------------------
1 | import {Router} from 'express';
2 | import recommend from '../services/recommend';
3 |
4 | const router = Router();
5 |
6 | router.get('/most-watched', recommend.getMostWatched);
7 |
8 | export default router;
9 |
--------------------------------------------------------------------------------
/server/controllers/sonarr.controller.js:
--------------------------------------------------------------------------------
1 | import {Router} from 'express';
2 | import sonarrService from '../services/sonarr';
3 |
4 | const router = Router();
5 |
6 | router.get('/search', sonarrService.search);
7 | router.get('/series/add', sonarrService.addSeries);
8 | router.get('/series', sonarrService.getSeries);
9 |
10 | export default router;
11 |
--------------------------------------------------------------------------------
/server/controllers/tdaw.controller.js:
--------------------------------------------------------------------------------
1 | import {Router} from 'express';
2 | import tdawService from '../services/tdaw';
3 |
4 | const router = Router();
5 |
6 | router.get('/similar', tdawService.similarMedia);
7 | router.get('/most-watched', tdawService.mostWatched);
8 | router.get('/qloo/media', tdawService.qlooMedia);
9 |
10 | export default router;
11 |
--------------------------------------------------------------------------------
/server/db/config/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "development": {
3 | "username": "postgres",
4 | "password": "postgres",
5 | "database": "recommend_development",
6 | "host": "127.0.0.1",
7 | "dialect": "postgres",
8 | "logging": false
9 | },
10 | "test": {
11 | "username": "postgres",
12 | "password": "postgres",
13 | "database": "recommend_test",
14 | "host": "127.0.0.1",
15 | "dialect": "postgres",
16 | "logging": false
17 | },
18 | "production": {
19 | "use_env_variable": "DATABASE_URL",
20 | "dialect": "postgres"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/db/migrations/20190224043920-create-user.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up(queryInterface, Sequelize) {
3 | return queryInterface.createTable('Users', {
4 | id: {
5 | allowNull: false,
6 | autoIncrement: true,
7 | primaryKey: true,
8 | type: Sequelize.INTEGER,
9 | },
10 | firstName: {
11 | type: Sequelize.STRING,
12 | },
13 | lastName: {
14 | type: Sequelize.STRING,
15 | },
16 | googleId: {
17 | type: Sequelize.STRING,
18 | },
19 | email: {
20 | type: Sequelize.STRING,
21 | unique: true,
22 | },
23 | plexUrl: {
24 | type: Sequelize.STRING,
25 | },
26 | plexToken: {
27 | type: Sequelize.STRING,
28 | },
29 | plexPinId: {
30 | type: Sequelize.STRING,
31 | },
32 | sonarrUrl: {
33 | type: Sequelize.STRING,
34 | },
35 | sonarrApiKey: {
36 | type: Sequelize.STRING,
37 | },
38 | createdAt: {
39 | allowNull: false,
40 | type: Sequelize.DATE,
41 | },
42 | updatedAt: {
43 | allowNull: false,
44 | type: Sequelize.DATE,
45 | },
46 | });
47 | },
48 | // eslint-disable-next-line no-unused-vars
49 | down: (queryInterface, Sequelize) => {
50 | return queryInterface.dropTable('Users');
51 | },
52 | };
53 |
--------------------------------------------------------------------------------
/server/db/migrations/20190224045315-create-plex-library.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: async (queryInterface, Sequelize) => {
3 | await queryInterface.createTable('PlexLibraries', {
4 | id: {
5 | allowNull: false,
6 | autoIncrement: true,
7 | primaryKey: true,
8 | type: Sequelize.INTEGER,
9 | },
10 | title: {
11 | type: Sequelize.STRING,
12 | },
13 | type: {
14 | type: Sequelize.STRING,
15 | },
16 | views: {
17 | type: Sequelize.INTEGER,
18 | },
19 | rating_key: {
20 | type: Sequelize.INTEGER,
21 | },
22 | poster_path: {
23 | type: Sequelize.STRING,
24 | },
25 | summary: {
26 | type: Sequelize.TEXT,
27 | },
28 | rating: {
29 | type: Sequelize.FLOAT,
30 | },
31 | year: {
32 | type: Sequelize.INTEGER,
33 | },
34 | genre: {
35 | type: Sequelize.STRING,
36 | },
37 | createdAt: {
38 | allowNull: false,
39 | type: Sequelize.DATE,
40 | },
41 | updatedAt: {
42 | allowNull: false,
43 | type: Sequelize.DATE,
44 | },
45 | });
46 | },
47 | // eslint-disable-next-line no-unused-vars
48 | down: (queryInterface, Sequelize) => {
49 | return queryInterface.dropTable('PlexLibraries');
50 | },
51 | };
52 |
--------------------------------------------------------------------------------
/server/db/migrations/20190224045418-create-plex-section.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: async (queryInterface, Sequelize) => {
3 | await queryInterface.createTable('PlexSections', {
4 | id: {
5 | allowNull: false,
6 | autoIncrement: true,
7 | primaryKey: true,
8 | type: Sequelize.INTEGER,
9 | },
10 | title: {
11 | type: Sequelize.STRING,
12 | },
13 | type: {
14 | type: Sequelize.STRING,
15 | },
16 | key: {
17 | type: Sequelize.INTEGER,
18 | },
19 | createdAt: {
20 | allowNull: false,
21 | type: Sequelize.DATE,
22 | },
23 | updatedAt: {
24 | allowNull: false,
25 | type: Sequelize.DATE,
26 | },
27 | });
28 | },
29 | // eslint-disable-next-line no-unused-vars
30 | down: (queryInterface, Sequelize) => {
31 | return queryInterface.dropTable('PlexSections');
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/server/db/migrations/20190901211623-add-user-library-association.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | up: (queryInterface, Sequelize) => {
5 | return queryInterface.addColumn(
6 | 'PlexLibraries', // name of Source model
7 | 'UserId', // name of the key we're adding
8 | {
9 | type: Sequelize.INTEGER,
10 | references: {
11 | model: 'Users', // name of Target model
12 | key: 'id', // key in Target model that we're referencing
13 | },
14 | onUpdate: 'CASCADE',
15 | onDelete: 'SET NULL',
16 | },
17 | );
18 | },
19 |
20 | down: (queryInterface, Sequelize) => {
21 | return queryInterface.removeColumn(
22 | 'PlexLibraries', // name of Source model
23 | 'UserId', // key we want to remove
24 | );
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/server/db/migrations/20190901212012-add-user-section-association.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | up: (queryInterface, Sequelize) => {
5 | return queryInterface.addColumn(
6 | 'PlexSections', // name of Source model
7 | 'UserId', // name of the key we're adding
8 | {
9 | type: Sequelize.INTEGER,
10 | references: {
11 | model: 'Users', // name of Target model
12 | key: 'id', // key in Target model that we're referencing
13 | },
14 | onUpdate: 'CASCADE',
15 | onDelete: 'SET NULL',
16 | },
17 | );
18 | },
19 |
20 | down: (queryInterface, Sequelize) => {
21 | return queryInterface.removeColumn(
22 | 'PlexSections', // name of Source model
23 | 'UserId', // key we want to remove
24 | );
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/server/db/migrations/20190903014701-add_admin_to_user.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: function (queryInterface, Sequelize) {
3 | // logic for transforming into the new state
4 | return queryInterface.addColumn(
5 | 'Users',
6 | 'admin',
7 | Sequelize.BOOLEAN
8 | );
9 |
10 | },
11 |
12 | down: function (queryInterface, Sequelize) {
13 | // logic for reverting the changes
14 | return queryInterface.removeColumn(
15 | 'Users',
16 | 'admin'
17 | );
18 | }
19 | }
--------------------------------------------------------------------------------
/server/db/migrations/20190905024038-add_password_to_user_model.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: function(queryInterface, Sequelize) {
3 | // logic for transforming into the new state
4 | return queryInterface.addColumn('Users', 'password', Sequelize.STRING);
5 | },
6 |
7 | down: function(queryInterface, Sequelize) {
8 | // logic for reverting the changes
9 | return queryInterface.removeColumn('Users', 'password');
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/server/db/models/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const Sequelize = require('sequelize');
6 | const basename = path.basename(__filename);
7 | const env = process.env.NODE_ENV || 'development';
8 | const config = require(__dirname + '/../config/config.json')[env];
9 | const db = {};
10 |
11 | let sequelize;
12 | if (config.use_env_variable) {
13 | sequelize = new Sequelize(process.env[config.use_env_variable], config);
14 | } else {
15 | sequelize = new Sequelize(config.database, config.username, config.password, config);
16 | }
17 |
18 | fs
19 | .readdirSync(__dirname)
20 | .filter(file => {
21 | return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
22 | })
23 | .forEach(file => {
24 | const model = sequelize['import'](path.join(__dirname, file));
25 | db[model.name] = model;
26 | });
27 |
28 | Object.keys(db).forEach(modelName => {
29 | if (db[modelName].associate) {
30 | db[modelName].associate(db);
31 | }
32 | });
33 |
34 | db.sequelize = sequelize;
35 | db.Sequelize = Sequelize;
36 |
37 | module.exports = db;
38 |
--------------------------------------------------------------------------------
/server/db/models/plexSection.js:
--------------------------------------------------------------------------------
1 | import Sequelize from 'sequelize';
2 | module.exports = (sequelize, DataTypes) => {
3 | const PlexSection = sequelize.define(
4 | 'PlexSection',
5 | {
6 | title: DataTypes.STRING,
7 | type: DataTypes.STRING,
8 | key: DataTypes.INTEGER,
9 | },
10 | {
11 | indexes: [
12 | {
13 | unique: true,
14 | fields: ['title', 'UserId'],
15 | },
16 | ],
17 | },
18 | );
19 | PlexSection.associate = function(models) {
20 | PlexSection.belongsTo(models.User);
21 | };
22 | return PlexSection;
23 | };
24 |
--------------------------------------------------------------------------------
/server/db/models/plexlibrary.js:
--------------------------------------------------------------------------------
1 | import Sequelize from 'sequelize';
2 | module.exports = (sequelize, DataTypes) => {
3 | const PlexLibrary = sequelize.define(
4 | 'PlexLibrary',
5 | {
6 | title: {type: DataTypes.STRING, unique: true},
7 | type: DataTypes.STRING,
8 | views: DataTypes.INTEGER,
9 | rating_key: DataTypes.INTEGER,
10 | poster_path: DataTypes.STRING,
11 | summary: DataTypes.TEXT,
12 | rating: DataTypes.FLOAT,
13 | year: DataTypes.INTEGER,
14 | genre: DataTypes.STRING,
15 | },
16 | {
17 | indexes: [
18 | {
19 | unique: true,
20 | fields: ['title', 'UserId'],
21 | },
22 | ],
23 | },
24 | );
25 | // eslint-disable-next-line no-unused-vars
26 | PlexLibrary.associate = function(models) {
27 | PlexLibrary.belongsTo(models.User);
28 | };
29 | return PlexLibrary;
30 | };
31 |
--------------------------------------------------------------------------------
/server/db/models/user.js:
--------------------------------------------------------------------------------
1 | import Sequelize from 'sequelize';
2 |
3 | module.exports = (sequelize, DataTypes) => {
4 | const User = sequelize.define(
5 | 'User',
6 | {
7 | firstName: DataTypes.STRING,
8 | lastName: DataTypes.STRING,
9 | googleId: DataTypes.STRING,
10 | email: {type: DataTypes.STRING, unique: true},
11 | plexUrl: DataTypes.STRING,
12 | plexPinId: DataTypes.STRING,
13 | plexToken: DataTypes.STRING,
14 | sonarrUrl: DataTypes.STRING,
15 | sonarrApiKey: DataTypes.STRING,
16 | admin: DataTypes.BOOLEAN,
17 | password: DataTypes.STRING,
18 | },
19 | {},
20 | );
21 | User.associate = function(models) {
22 | User.hasMany(models.PlexSection);
23 | User.hasMany(models.PlexLibrary);
24 | };
25 | return User;
26 | };
27 |
--------------------------------------------------------------------------------
/server/db/scripts/index.js:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | /* istanbul ignore file */
3 | import { exec } from 'child_process';
4 | import models from '../models';
5 | import seeds from '../seeders';
6 | import { test as config } from '../config/config';
7 |
8 | const { username, host, database } = config;
9 |
10 | const EXIT_CODE = 168;
11 |
12 | const migrate = () =>
13 | new Promise((resolve, reject) => {
14 | exec(
15 | './node_modules/.bin/sequelize db:migrate --env test',
16 | (err, stdout) => {
17 | if (err) {
18 | console.log('Error migrating DB.');
19 | reject(EXIT_CODE);
20 | } else {
21 | console.log(stdout);
22 | resolve();
23 | }
24 | },
25 | );
26 | });
27 |
28 | const createDB = () =>
29 | new Promise((resolve, reject) => {
30 | exec(
31 | `createdb -U ${username} -h ${host} ${database}`,
32 | (err, stdout, stderr) => {
33 | if (err || stderr) {
34 | console.log('Error creating DB');
35 | console.log(err || stderr);
36 | reject(EXIT_CODE);
37 | } else {
38 | console.log('Created DB.');
39 | resolve();
40 | }
41 | },
42 | );
43 | });
44 |
45 | const dropDB = () =>
46 | new Promise((resolve, reject) => {
47 | exec(
48 | `dropdb -U ${username} -h ${host} ${database}`,
49 | (err, stdout2, stderr) => {
50 | if (err || stderr) {
51 | console.log('Error dropping DB');
52 | console.log(err || stderr);
53 | reject(EXIT_CODE);
54 | } else {
55 | console.log('Dropped DB.');
56 | resolve();
57 | }
58 | },
59 | );
60 | });
61 |
62 | const unmigrate = () =>
63 | new Promise((resolve, reject) => {
64 | exec(
65 | './node_modules/.bin/sequelize db:migrate:undo:all --env test',
66 | (err, stdout) => {
67 | if (err) {
68 | console.log('Error unmigrating DB.');
69 | reject(EXIT_CODE);
70 | } else {
71 | console.log(stdout);
72 | resolve();
73 | }
74 | },
75 | );
76 | });
77 |
78 | const cleanUp = () =>
79 | unmigrate()
80 | .then(() => models.sequelize.close())
81 | .then(dropDB);
82 |
83 | const init = () =>
84 | createDB()
85 | .catch(() => dropDB().then(createDB))
86 | .then(migrate);
87 |
88 | const truncate = model =>
89 | models[model]
90 | .destroy({
91 | truncate: { restartIdentity: true, cascade: true },
92 | })
93 | .catch(e => {
94 | console.log('Error truncating', model);
95 | console.log(e);
96 | process.exit(EXIT_CODE);
97 | });
98 |
99 | const seed = (model, collection = model) => {
100 | const seedForModel = seeds[collection];
101 | return models[model].bulkCreate(seedForModel).catch(err => {
102 | console.log(err);
103 | console.log('Error seeding', model);
104 | process.exit(EXIT_CODE);
105 | });
106 | };
107 |
108 | export { init, truncate, seed, cleanUp };
109 |
--------------------------------------------------------------------------------
/server/db/seeders/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | Users: [
3 | {
4 | id: 1,
5 | firstName: 'Mike',
6 | lastName: 'Rode',
7 | googleId: '101111197386111111151',
8 | email: 'mike@email.com',
9 | plexUrl: 'https://plex.mjrflix.com',
10 | plexPinId: '5425341',
11 | plexToken: 'testPlexApiToken',
12 | sonarrUrl: null,
13 | sonarrApiKey: null,
14 | },
15 | {
16 | id: 999,
17 | firstName: 'Test',
18 | lastName: 'User',
19 | googleId: '111111111111',
20 | email: 'test@email.com',
21 | plexUrl: 'https://plex.testlix.com',
22 | plexPinId: '11111',
23 | plexToken: 'testPlexApiToken',
24 | sonarrUrl: null,
25 | sonarrApiKey: null,
26 | },
27 | ],
28 |
29 | PlexLibrary: [
30 | {
31 | id: 1,
32 | title: 'Ash vs Evil Dead',
33 | type: 'show',
34 | views: null,
35 | rating_key: 5471,
36 | poster_path: null,
37 | summary:
38 | "Ash has spent the last thirty years avoiding responsibility, maturity, and the terrors of the Evil Dead until a Deadite plague threatens to destroy all of mankind and Ash becomes mankind's only hope. ",
39 | rating: 8.7,
40 | year: 2015,
41 | genre: '[{"tag":"Comedy"},{"tag":"Horror"}]',
42 | createdAt: '2019-09-01 18:34:42.659-05',
43 | updatedAt: '2019-09-01 18:34:42.796-05',
44 | UserId: 999,
45 | },
46 | {
47 | id: 2,
48 | title: 'Anthony Bourdain: Parts Unknown',
49 | type: 'show',
50 | views: null,
51 | rating_key: 8121,
52 | poster_path: null,
53 | summary:
54 | 'Bourdain traveled across the globe to uncover little-known areas of the world and celebrate diverse cultures by exploring food and dining rituals. Known for his curiosity, candor, and acerbic wit, Bourdain took viewers off the beaten path of tourist destinations – including some war-torn parts of the world – and met with a variety of local citizens to offer a window into their lifestyles, and occasionally communed with an internationally lauded chef on his journey. We can all hope to find ourselves to enjoy a life as amazing as Anthony Bourdain did. Rest in peace.',
55 | rating: 8.3,
56 | year: 2013,
57 | genre: null,
58 | createdAt: '2019-09-01 18:34:42.659-05',
59 | updatedAt: '2019-09-01 18:34:42.784-05',
60 | UserId: 999,
61 | },
62 | {
63 | id: 3,
64 | title: 'Atlanta',
65 | type: 'show',
66 | views: null,
67 | rating_key: 6937,
68 | poster_path: null,
69 | summary:
70 | "Two cousins, with different views on art versus commerce, on their way up through the Atlanta rap scene; Earnest 'Earn' Marks, an ambitious college dropout and his estranged cousin, who suddenly becomes a star.",
71 | rating: 8.5,
72 | year: 2016,
73 | genre: '[{"tag":"Comedy"},{"tag":"Drama"}]',
74 | createdAt: '2019-09-01 18:34:42.666-05',
75 | updatedAt: '2019-09-01 18:34:42.796-05',
76 | UserId: 999,
77 | },
78 | {
79 | id: 4,
80 | title: 'Avatar: The Last Airbender',
81 | type: 'show',
82 | views: null,
83 | rating_key: 7060,
84 | poster_path: null,
85 | summary:
86 | 'With the Fire Nation on the brink of global domination, a young girl and her brother discover a 12-year old Airbender who reveals himself as the Avatar. Will this irresponsible kid accept his destiny in time to save the world?',
87 | rating: 9,
88 | year: 2005,
89 | genre: '[{"tag":"Action"},{"tag":"Adventure"}]',
90 | createdAt: '2019-09-01 18:34:42.666-05',
91 | updatedAt: '2019-09-01 18:34:42.801-05',
92 | UserId: 999,
93 | },
94 | {
95 | id: 5,
96 | title: '30 Rock',
97 | type: 'show',
98 | views: null,
99 | rating_key: 2529,
100 | poster_path: null,
101 | summary:
102 | "Emmy Award Winner Tina Fey writes, executive produces and stars as Liz Lemon, the head writer of a live variety programme in New York City. Liz's life is turned upside down when brash new network executive Jack Donaghy (Alec Baldwin in his Golden Globe winning role) interferes with her show, bringing the wildly unpredictable Tracy Jordan (Tracy Morgan) into the cast. Now its up to Liz to manage the mayhem and still try to have a life.\r\n",
103 | rating: 8.6,
104 | year: 2006,
105 | genre: '[{"tag":"Comedy"}]',
106 | createdAt: '2019-09-01 18:34:42.666-05',
107 | updatedAt: '2019-09-01 18:34:42.783-05',
108 | UserId: 999,
109 | },
110 | {
111 | id: 6,
112 | title: 'Band of Brothers',
113 | type: 'show',
114 | views: null,
115 | rating_key: 6925,
116 | poster_path: null,
117 | summary:
118 | 'The miniseries follows Easy Company, an army unit during World War II, from their initial training at Camp Toccoa to the conclusion of the war. The series is based on the book written by the late Stephen E. Ambrose. \r\n\r\nBand of Brothers is executive produced by Steven Spielberg and Tom Hanks, the series won 6 Emmy Awards. ',
119 | rating: 9.4,
120 | year: 2001,
121 | genre: '[{"tag":"Action"},{"tag":"Adventure"}]',
122 | createdAt: '2019-09-01 18:34:42.672-05',
123 | updatedAt: '2019-09-01 18:34:42.801-05',
124 | UserId: 999,
125 | },
126 | {
127 | id: 7,
128 | title: 'Barry',
129 | type: 'show',
130 | views: null,
131 | rating_key: 7026,
132 | poster_path: null,
133 | summary:
134 | 'After following his intended target to an acting class, a hitman finds himself intrigued and decides to become an actor and change his life.',
135 | rating: 7.8,
136 | year: 2018,
137 | genre: '[{"tag":"Comedy"},{"tag":"Crime"}]',
138 | createdAt: '2019-09-01 18:34:42.672-05',
139 | updatedAt: '2019-09-01 18:34:42.801-05',
140 | UserId: 999,
141 | },
142 | {
143 | id: 8,
144 | title: 'Better Call Saul',
145 | type: 'show',
146 | views: null,
147 | rating_key: 5836,
148 | poster_path: null,
149 | summary:
150 | "See the rise and rise of Jimmy McGill, a small-time lawyer searching for his destiny, but hustling to make ends meet. Working alongside, and often against Jimmy, is ‘fixer’ Mike Erhmantraut. The series tracks Jimmy's evolution into Saul Goodman, the man who puts the word ‘criminal’ in ‘criminal lawyer’.",
151 | rating: 8.9,
152 | year: 2015,
153 | genre: '[{"tag":"Crime"},{"tag":"Drama"}]',
154 | createdAt: '2019-09-01 18:34:42.672-05',
155 | updatedAt: '2019-09-01 18:34:42.812-05',
156 | UserId: 999,
157 | },
158 | {
159 | id: 9,
160 | title: 'Big Mouth',
161 | type: 'show',
162 | views: null,
163 | rating_key: 8257,
164 | poster_path: null,
165 | summary:
166 | 'Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.',
167 | rating: 8.3,
168 | year: 2017,
169 | genre: '[{"tag":"Animation"},{"tag":"Comedy"}]',
170 | createdAt: '2019-09-01 18:34:42.678-05',
171 | updatedAt: '2019-09-01 18:34:42.812-05',
172 | UserId: 999,
173 | },
174 | {
175 | id: 10,
176 | title: 'Big Time in Hollywood, FL',
177 | type: 'show before import',
178 | views: null,
179 | rating_key: 8102,
180 | poster_path: null,
181 | summary:
182 | "Follows two delusional brothers, who are self-proclaimed filmmakers, as they are kicked out of their parent's house and end up on an epic cinematic journey.",
183 | rating: 8,
184 | year: 2015,
185 | genre: '[{"tag":"Comedy"}]',
186 | createdAt: '2019-09-01 18:34:42.678-05',
187 | updatedAt: '2019-09-01 18:34:42.817-05',
188 | UserId: 1,
189 | },
190 | ],
191 | PlexSections: [
192 | {
193 | id: 2,
194 | title: 'TV Shows',
195 | type: 'show',
196 | key: 3,
197 | UserId: 999,
198 | },
199 | {
200 | id: 3,
201 | title: 'Movies',
202 | type: 'movie',
203 | key: 2,
204 | UserId: 999,
205 | },
206 | ],
207 |
208 | PlexSection: [
209 | {
210 | id: 1,
211 | title: 'TV Shows',
212 | type: 'show',
213 | key: 4,
214 | UserId: 1,
215 | },
216 | ],
217 | };
218 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { json, urlencoded } from 'body-parser';
3 | // eslint-disable-next-line import/named
4 | import passport from 'passport';
5 | import cookieSession from 'cookie-session';
6 | import cookieParser from 'cookie-parser';
7 | import morganBody from 'morgan-body';
8 | import models from './db/models';
9 | import keys from '../config';
10 | import plex from './routes/plex.route';
11 | import tdaw from './routes/tdaw.route';
12 | import movieDb from './routes/movieDb.route';
13 | import sonarr from './routes/sonarr.route';
14 | import auth from './routes/auth.route';
15 | import admin from './routes/admin.route';
16 | import recommend from './routes/recommend.route';
17 | import winston from '../config/winston';
18 |
19 | require('./services/auth/passport');
20 |
21 | export default () => {
22 | const server = express();
23 |
24 | const create = config => {
25 | // Server settings
26 | server.set('env', config.env);
27 | server.set('port', config.server.port);
28 | server.set('hostname', config.server.hostname);
29 |
30 | // Returns middleware that parses json
31 | server.use(json());
32 | server.use(urlencoded({ extended: true }));
33 | morganBody(server, {
34 | stream: winston.stream,
35 | });
36 |
37 | server.use(
38 | cookieSession({
39 | maxAge: 30 * 24 * 60 * 60 * 1000,
40 | keys: [keys.server.cookieKey],
41 | }),
42 | );
43 | server.use(cookieParser(keys.server.cookieKey));
44 | server.use(passport.initialize());
45 | server.use(passport.session());
46 |
47 | // Set up routes
48 | server.use('/api/plex', plex);
49 | server.use('/api/tdaw', tdaw);
50 | server.use('/api/moviedb', movieDb);
51 | server.use('/api/sonarr', sonarr);
52 | server.use('/api/recommend', recommend);
53 | server.use('/auth', auth);
54 | server.use('/api/auth', auth);
55 | server.use('/api/admin', admin);
56 |
57 | if (process.env.NODE_ENV === 'production') {
58 | server.use(express.static('client/build'));
59 | const path = require('path');
60 | server.get('*', (req, res) => {
61 | res.sendFile(
62 | path.resolve(
63 | __dirname,
64 | '..',
65 | 'client',
66 | 'build',
67 | 'index.html',
68 | ),
69 | );
70 | });
71 | }
72 |
73 | server.get('*', function(req, res, next) {
74 | const err = new Error(
75 | `Page Not Found at route ${req.originalUrl}`,
76 | );
77 | err.statusCode = 404;
78 | next(err);
79 | });
80 |
81 | // eslint-disable-next-line no-unused-vars
82 | server.use(function(err, req, res, next) {
83 | // set locals, only providing error in development
84 | console.log('wasI called from here');
85 | res.locals.message = err.message;
86 | res.locals.error =
87 | req.app.get('env') === 'development' ? err : {};
88 |
89 | winston.error(
90 | `${err.status || 500} - ${err.message} - ${
91 | req.originalUrl
92 | } - ${req.method} - ${req.ip}`,
93 | );
94 |
95 | // eslint-disable-next-line no-param-reassign
96 | if (!err.statusCode) err.statusCode = 500; // If err has no specified error code, set error code to 'Internal Server Error (500)'
97 | res.status(err.statusCode).send(err.message); // All HTTP requests must have a response, so let's send back an error with its status code and message
98 | });
99 | return server;
100 | };
101 |
102 | const start = () => {
103 | const hostname = server.get('hostname');
104 |
105 | const port = server.get('port') || 8080;
106 |
107 | models.Sequelize.Op;
108 | server.listen(port, () => {
109 | console.log(
110 | `Express server listening on - http://${hostname}:${port}`,
111 | );
112 | });
113 | };
114 |
115 | process.on('SIGINT', function() {
116 | console.log('SIGINT');
117 | process.exit();
118 | });
119 |
120 | process.on('unhandledRejection', (reason, p) => {
121 | console.log(
122 | 'Unhandled Rejection at: Promise',
123 | p,
124 | 'reason:',
125 | reason,
126 | );
127 | });
128 |
129 | return { create, start };
130 | };
131 |
--------------------------------------------------------------------------------
/server/initialize.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 |
4 | import dotenv from 'dotenv';
5 | import defaults from 'lodash/defaults';
6 |
7 | // Default environment is development to prevent "accidents"
8 | process.env.NODE_ENV = process.env.NODE_ENV || 'development';
9 |
10 | // Reads in the needed config file from config/
11 | const env = dotenv.parse(
12 | fs.readFileSync(
13 | path.resolve(__dirname, '..', 'config', `${process.env.NODE_ENV}.env`),
14 | ),
15 | );
16 |
17 | // Sets all values from the config file
18 | defaults(process.env, env);
19 |
--------------------------------------------------------------------------------
/server/routes/admin.route.js:
--------------------------------------------------------------------------------
1 | import adminController from '../controllers/admin.controller';
2 |
3 | const express = require('express');
4 |
5 | const router = express.Router();
6 |
7 | router.use(adminController);
8 |
9 | export default router;
10 |
--------------------------------------------------------------------------------
/server/routes/auth.route.js:
--------------------------------------------------------------------------------
1 | import authController from '../controllers/auth.controller';
2 |
3 | const express = require('express');
4 |
5 | const router = express.Router();
6 |
7 | router.use(authController);
8 |
9 | export default router;
10 |
--------------------------------------------------------------------------------
/server/routes/movieDb.route.js:
--------------------------------------------------------------------------------
1 | import movieDbController from '../controllers/movieDb.controller';
2 |
3 | const express = require('express');
4 |
5 | const router = express.Router();
6 |
7 | router.use(movieDbController);
8 |
9 | export default router;
10 |
--------------------------------------------------------------------------------
/server/routes/plex.route.js:
--------------------------------------------------------------------------------
1 | import plexController from '../controllers/plex.controller';
2 |
3 | const express = require('express');
4 |
5 | const router = express.Router();
6 |
7 | router.use(plexController);
8 |
9 | export default router;
10 |
--------------------------------------------------------------------------------
/server/routes/recommend.route.js:
--------------------------------------------------------------------------------
1 | import recommendController from '../controllers/recommend.controller';
2 |
3 | const express = require('express');
4 |
5 | const router = express.Router();
6 |
7 | router.use(recommendController);
8 |
9 | export default router;
10 |
--------------------------------------------------------------------------------
/server/routes/sonarr.route.js:
--------------------------------------------------------------------------------
1 | import sonarrController from '../controllers/sonarr.controller';
2 |
3 | const express = require('express');
4 |
5 | const router = express.Router();
6 |
7 | router.use(sonarrController);
8 |
9 | export default router;
10 |
--------------------------------------------------------------------------------
/server/routes/tdaw.route.js:
--------------------------------------------------------------------------------
1 | import tdawController from '../controllers/tdaw.controller';
2 |
3 | const express = require('express');
4 |
5 | const router = express.Router();
6 |
7 | router.use(tdawController);
8 |
9 | export default router;
10 |
--------------------------------------------------------------------------------
/server/services/auth/passport.js:
--------------------------------------------------------------------------------
1 | const bCrypt = require('bcrypt-nodejs');
2 | const passport = require('passport');
3 | const LocalStrategy = require('passport-local').Strategy;
4 |
5 | const GoogleStrategy =
6 | process.env.NODE_ENV == 'test'
7 | ? require('passport-mocked').Strategy
8 | : require('passport-google-oauth20').Strategy;
9 |
10 | import keys from '../../../config';
11 |
12 | import models from '../../db/models';
13 |
14 | passport.serializeUser((user, done) => {
15 | done(null, user.id);
16 | });
17 |
18 | passport.deserializeUser((id, done) => {
19 | models.User.findByPk(id).then(user => {
20 | done(null, user);
21 | });
22 | });
23 |
24 | const generateHash = password => {
25 | return bCrypt.hashSync(password, bCrypt.genSaltSync(8), null);
26 | };
27 |
28 | passport.use(
29 | 'local-signup',
30 | new LocalStrategy(
31 | {
32 | usernameField: 'email',
33 | passwordField: 'password',
34 | },
35 |
36 | async function(email, password, done) {
37 | const exisitingUser = await models.User.findOne({
38 | where: { email: email },
39 | returning: true,
40 | plain: true,
41 | raw: true,
42 | });
43 | if (exisitingUser) {
44 | return done(null, false, {
45 | message: 'That email is already taken',
46 | });
47 | }
48 | const userPassword = generateHash(password);
49 |
50 | const data = {
51 | email: email,
52 | password: userPassword,
53 | };
54 | const newUser = models.User.create(data, {
55 | returning: true,
56 | plain: true,
57 | raw: true,
58 | }).then(function(newUser, created) {
59 | if (!newUser) {
60 | return done(null, false);
61 | }
62 | if (newUser) {
63 | return done(null, newUser);
64 | }
65 | });
66 | },
67 | ),
68 | );
69 |
70 | //LOCAL SIGNIN
71 | passport.use(
72 | 'fake-session',
73 | new LocalStrategy(
74 | {
75 | usernameField: 'email',
76 | passwordField: 'password',
77 | },
78 |
79 | function(email, admin, done) {
80 | var isValidPassword = function(email, admin) {
81 | return admin === true;
82 | };
83 |
84 | models.User.findOne({
85 | where: {
86 | email: email,
87 | },
88 | returning: true,
89 | plain: true,
90 | raw: true,
91 | })
92 | .then(function(user) {
93 | if (!user) {
94 | return done(null, false, {
95 | message: 'Email does not exist',
96 | });
97 | }
98 |
99 | if (!isValidPassword(email, admin)) {
100 | return done(null, false, {
101 | message: 'Incorrect password.',
102 | });
103 | }
104 |
105 | return done(null, user);
106 | })
107 | .catch(function(err) {
108 | return done(null, false, {
109 | message: 'Something went wrong with your Signin',
110 | });
111 | });
112 | },
113 | ),
114 | );
115 |
116 | //LOCAL SIGNIN
117 | passport.use(
118 | 'local-login',
119 | new LocalStrategy(
120 | {
121 | usernameField: 'email',
122 | passwordField: 'password',
123 | },
124 |
125 | function(email, password, done) {
126 | var isValidPassword = function(userpass, password) {
127 | return bCrypt.compareSync(password, userpass);
128 | };
129 |
130 | models.User.findOne({
131 | where: {
132 | email: email,
133 | },
134 | returning: true,
135 | plain: true,
136 | raw: true,
137 | })
138 | .then(function(user) {
139 | if (!user) {
140 | return done(null, false, {
141 | message: 'Email does not exist',
142 | });
143 | }
144 |
145 | if (!isValidPassword(user.password, password)) {
146 | return done(null, false, {
147 | message: 'Incorrect password.',
148 | });
149 | }
150 |
151 | return done(null, user);
152 | })
153 | .catch(function(err) {
154 | console.log('Error:', err);
155 |
156 | return done(null, false, {
157 | message: 'Something went wrong with your Signin',
158 | });
159 | });
160 | },
161 | ),
162 | );
163 |
164 | passport.use(
165 | 'google',
166 | new GoogleStrategy(
167 | {
168 | clientID: keys.server.googleClientID,
169 | clientSecret: keys.server.googleClientSecret,
170 | callbackURL: '/api/auth/google/callback',
171 | proxy: true,
172 | },
173 | async (accessToken, refreshToken, profile, done) => {
174 | const email = profile.emails ? profile.emails[0].value : null;
175 |
176 | const existingUser = await models.User.findOne({
177 | where: { email },
178 | });
179 | if (existingUser) {
180 | return done(null, existingUser);
181 | }
182 | const user = await models.User.create({
183 | firstName: profile.name.givenName,
184 | lastName: profile.name.familyName,
185 | email: profile.emails[0].value,
186 | googleId: profile.id,
187 | });
188 | done(null, user);
189 | },
190 | ),
191 | );
192 |
--------------------------------------------------------------------------------
/server/services/helpers.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import parser from 'xml2json';
3 | import buildUrlPackage from 'build-url';
4 | import logger from '../../config/winston';
5 | import { inspect } from 'util';
6 |
7 | const formatResponse = response => {
8 | logger.info(
9 | `API request url: ${response.config.method} ${inspect(
10 | response.config.url,
11 | )}`,
12 | );
13 | logger.info(
14 | `API response status: ${inspect(
15 | response.status,
16 | )} API response length: ${inspect(
17 | response.headers['content-length'],
18 | )}`,
19 | );
20 | logger.silly(`API response data: ${inspect(response.data)}`);
21 | const xmlResponse = response.headers['content-type'].includes(
22 | 'xml',
23 | );
24 | if (xmlResponse) {
25 | return JSON.parse(parser.toJson(response.data));
26 | }
27 | if (response.config.url.includes('tastedive')) {
28 | return response.data.Similar.Results;
29 | }
30 | return response.data;
31 | };
32 |
33 | const fixedEncodeURIComponent = str => {
34 | return encodeURIComponent(str).replace(/[!'()*]/g, c => {
35 | return '%' + c.charCodeAt(0).toString(16);
36 | });
37 | };
38 |
39 | const buildUrl = function(urlParams) {
40 | try {
41 | const params = urlParams;
42 | const { host } = params;
43 | delete params.host;
44 | const urlHash = params;
45 |
46 | if (typeof urlHash !== 'object') {
47 | throw new Error(`Invalid urlParams: ${urlHash}`);
48 | }
49 | return buildUrlPackage(host, urlHash);
50 | } catch (error) {
51 | return error;
52 | }
53 | };
54 |
55 | const request = async function(url) {
56 | return new Promise((resolve, reject) => {
57 | const httpClient = axios;
58 | httpClient
59 | .get(url)
60 | .then(response => {
61 | return resolve(formatResponse(response));
62 | })
63 | .catch(error => {
64 | if (error.response) {
65 | logger.error(`Error: Status --, ${error.response.status}`);
66 | logger.error(
67 | `Error: URL --, ${inspect(error.request.path)}`,
68 | );
69 | logger.error(`Error: Response --, ${error.response.data}`);
70 | return reject(error.response);
71 | }
72 | if (error.request) {
73 | // eslint-disable-next-line no-underscore-dangle
74 | logger.error(
75 | `Error request path: ${error.request._options.path} ${error}`,
76 | );
77 | } else {
78 | logger.error(`Error: ${error.message}`);
79 | }
80 | return reject(error);
81 | });
82 | });
83 | };
84 |
85 | const handleError = (res, method) => err => {
86 | logger.error(`Error in ${method}`);
87 | const { code, message } = err.responseData || {
88 | code: 500,
89 | message: 'An unknown error occurred.',
90 | };
91 | res.status(code).json({ message });
92 | };
93 |
94 | export default {
95 | formatResponse,
96 | buildUrl,
97 | request,
98 | handleError,
99 | fixedEncodeURIComponent,
100 | };
101 |
--------------------------------------------------------------------------------
/server/services/moviedb/index.js:
--------------------------------------------------------------------------------
1 | import movieDbApi from './movieDbApi';
2 | import models from '../../db/models';
3 | import helpers from '../helpers';
4 | import { Op } from 'sequelize';
5 | import logger from '../../../config/winston';
6 |
7 | const searchTv = async (req, res) => {
8 | const { showName } = req.query;
9 | const response = await movieDbApi.searchTv(showName);
10 | res.json(response);
11 | };
12 |
13 | const popularTv = async (req, res) => {
14 | const response = await movieDbApi.popularTv();
15 | // const jsonLibrary = await models.PlexLibrary.findAll({
16 | // userId: req.user.id,
17 | // type: 'show',
18 | // });
19 | // const libraryTitles = jsonLibrary.map(show => show.title.toLowerCase());
20 | // const filteredResponse = response.results.filter(
21 | // show => !libraryTitles.includes(show.name.toLowerCase()),
22 | // );
23 | res.json(response.results);
24 | };
25 |
26 | const topRatedTv = async (req, res) => {
27 | const response = await movieDbApi.topRatedTv();
28 | // const jsonLibrary = await models.PlexLibrary.findAll({
29 | // userId: req.user.id,
30 | // type: 'show',
31 | // });
32 | // const libraryTitles = jsonLibrary.map(show => show.title.toLowerCase());
33 | // // const filteredResponse = response.results.filter(
34 | // show => !libraryTitles.includes(show.name.toLowerCase()),
35 | // );
36 | res.json(response.results);
37 | };
38 |
39 | const similarTv = async (req, res) => {
40 | const { showName } = req.query;
41 | const formattedShowName = showName.replace(/ *\([^)]*\) */g, '');
42 | logger.info(
43 | `Formatted show name for similar search: ${formattedShowName}`,
44 | );
45 | const searchResponse = await movieDbApi.searchTv(formattedShowName);
46 | logger.info(`TCL: similarTv -> similarResponse ${searchResponse}`);
47 |
48 | const similarResponse = await movieDbApi.similarTV(
49 | searchResponse.id,
50 | );
51 | logger.info(`TCL: similarTv -> similarResponse ${similarResponse}`);
52 | // TODO: alert users when no shows are returned
53 |
54 | const jsonLibrary = await models.PlexLibrary.findAll({
55 | userId: req.user.id,
56 | type: 'show',
57 | });
58 |
59 | const libraryTitles = jsonLibrary.map(show =>
60 | show.title.toLowerCase(),
61 | );
62 | const filteredResponse = similarResponse.results.filter(
63 | show => !libraryTitles.includes(show.name.toLowerCase()),
64 | );
65 | res.json(filteredResponse);
66 | };
67 |
68 | export default {
69 | searchTv,
70 | similarTv,
71 | popularTv,
72 | topRatedTv,
73 | };
74 |
--------------------------------------------------------------------------------
/server/services/moviedb/movieDbApi.js:
--------------------------------------------------------------------------------
1 | import config from '../../../config';
2 | import helpers from '../helpers';
3 | import models from '../../db/models';
4 | import MovieDb from 'moviedb-promise';
5 | import stringSimilarity from 'string-similarity';
6 |
7 | const mdb = new MovieDb(config.server.movieApiKey);
8 |
9 | const popularTv = async () => {
10 | try {
11 | const response = await mdb.miscPopularTvs();
12 | return response;
13 | } catch (error) {
14 | helpers.handleError(error, 'popularTv');
15 | }
16 | };
17 |
18 | const topRatedTv = async () => {
19 | try {
20 | const response = await mdb.miscTopRatedTvs();
21 | return response;
22 | } catch (error) {
23 | helpers.handleError(error, 'miscTopRatedTvs');
24 | }
25 | };
26 |
27 | const searchTv = async showName => {
28 | try {
29 | const response = await mdb.searchTv({
30 | query: showName,
31 | });
32 | const stringSim = await stringSimilarity.compareTwoStrings(
33 | response.results[0].original_name.toLowerCase(),
34 | showName.toLowerCase(),
35 | );
36 | console.log('string similarity', stringSim);
37 | const show = response.results.filter(
38 | result =>
39 | stringSimilarity.compareTwoStrings(
40 | result.original_name.toLowerCase(),
41 | showName.toLowerCase(),
42 | ) > 0.75,
43 | )[0];
44 |
45 | console.log('movie db api search', show);
46 | return show;
47 | } catch (error) {
48 | helpers.handleError(error, 'searchTv');
49 | }
50 | };
51 |
52 | const similarTV = async showId => {
53 | try {
54 | const response = await mdb.tvSimilar({id: showId});
55 | return response;
56 | } catch (error) {
57 | helpers.handleError(error, 'searchTv');
58 | }
59 | };
60 |
61 | export default {searchTv, similarTV, popularTv, topRatedTv};
62 |
--------------------------------------------------------------------------------
/server/services/plex/auth.js:
--------------------------------------------------------------------------------
1 | import parser from 'xml2json';
2 | import uuid from 'uuid';
3 | import btoa from 'btoa';
4 | import request from 'request-promise';
5 | import models from '../../db/models';
6 | import logger from '../../../config/winston';
7 |
8 | const getPlexPin = async user => {
9 | try {
10 | logger.info(`getPlexPin(User) ${user}`);
11 | const params = {
12 | url: 'https://plex.tv/pins.xml',
13 | headers: {
14 | 'X-Plex-Client-Identifier': user.email,
15 | },
16 | };
17 | const res = await request.post(params);
18 | const formattedResponse = JSON.parse(parser.toJson(res));
19 |
20 | return formattedResponse;
21 | } catch (error) {
22 | logger.error(error);
23 | return error.message;
24 | }
25 | };
26 |
27 | const fetchToken = async (username, password) => {
28 | try {
29 | const res = await request.post(
30 | tokenUrlParams(username, password),
31 | );
32 | const token = res.match(rxAuthToken)[1];
33 | return token;
34 | } catch (error) {
35 | return error.message;
36 | }
37 | };
38 |
39 | const tokenUrlParams = (username, password) => ({
40 | url: 'https://plex.tv/users/sign_in.xml',
41 | headers: {
42 | 'X-Plex-Client-Identifier': uuid(),
43 | Authorization: `Basic ${encryptUserCreds(username, password)}`,
44 | },
45 | });
46 |
47 | const plexUrlParams = (plexToken, user) => ({
48 | url: 'https://plex.tv/pms/servers.xml',
49 | headers: {
50 | 'X-Plex-Client-Identifier': user.user.email,
51 | 'X-Plex-Token': plexToken,
52 | },
53 | });
54 |
55 | const checkPlexPin = async (pinId, user) => {
56 | try {
57 | const params = {
58 | url: `https://plex.tv/pins/${pinId}.xml`,
59 | headers: {
60 | 'X-Plex-Client-Identifier': user.googleId,
61 | },
62 | };
63 | const res = await request.get(params);
64 | const formattedResponse = JSON.parse(parser.toJson(res));
65 | return formattedResponse.pin.auth_token;
66 | } catch (error) {
67 | console.log(error);
68 | return error.message;
69 | }
70 | };
71 |
72 | const getPlexUrl = async (plexToken, user) => {
73 | try {
74 | const res = await request.get(plexUrlParams(plexToken, user));
75 | let formattedResponse = JSON.parse(parser.toJson(res))
76 | .MediaContainer.Server;
77 |
78 | if (!Array.isArray(formattedResponse)) {
79 | formattedResponse = [formattedResponse];
80 | }
81 | formattedResponse.reduce((acc, other) =>
82 | acc.createdAt > other.createdAt ? acc : other,
83 | );
84 |
85 | const server = formattedResponse.slice(-1)[0];
86 |
87 | await models.User.update(
88 | {
89 | plexToken: plexToken.trim(),
90 | plexUrl: `http://${server.address}:${server.port}`.trim(),
91 | },
92 | { where: { googleId: user.googleId } },
93 | );
94 | console.log('server--', server);
95 | return `http://${server.address}:${server.port}`;
96 | } catch (error) {
97 | console.log(error.message);
98 | return error.message;
99 | }
100 | };
101 |
102 | const rxAuthToken = /authenticationToken="([^"]+)"/;
103 |
104 | const encryptUserCreds = (username, password) => {
105 | const creds = `${username}:${password}`;
106 | return btoa(creds);
107 | };
108 |
109 | export default { fetchToken, getPlexPin, checkPlexPin, getPlexUrl };
110 |
--------------------------------------------------------------------------------
/server/services/plex/importData.js:
--------------------------------------------------------------------------------
1 | import Promise from 'bluebird';
2 | import plexApi from './plexApi';
3 | import models from '../../db/models';
4 | import config from '../../../config';
5 | import MovieDb from 'moviedb-promise';
6 | import logger from '../../../config/winston';
7 | import { Op } from 'sequelize';
8 | const mdb = new MovieDb(config.server.movieApiKey);
9 |
10 | const updateOrCreate = async (model, where, newItem) => {
11 | const item = await model.findOne({ where });
12 | if (!item) {
13 | const createItem = await model.create(newItem, {
14 | returning: true,
15 | plain: true,
16 | raw: true,
17 | });
18 | return { createItem, created: true };
19 | } else {
20 | await model.update(
21 | newItem,
22 | { where: where },
23 | { returning: true, plain: true, raw: true },
24 | );
25 | return { item, created: false };
26 | }
27 | };
28 |
29 | const importTvPosters = async user => {
30 | try {
31 | const mostWatched = await models.PlexLibrary.findAll({
32 | where: { UserId: user.id, type: 'show', views: { [Op.gt]: 0 } },
33 | });
34 |
35 | await mostWatched.map(async show => {
36 | const res = await mdb.searchTv({
37 | query: show.title.replace(/ *\([^)]*\) */g, ''),
38 | });
39 | logger.info(`Poster response ${show.title} ${res.results}`);
40 | return models.PlexLibrary.update(
41 | {
42 | poster_path: res.results[0].poster_path,
43 | },
44 | {
45 | where: { UserId: user.id, title: show.title },
46 | },
47 | );
48 | });
49 | } catch (error) {
50 | return error.message;
51 | }
52 | };
53 |
54 | const importSections = async user => {
55 | const sections = await plexApi.getSections(user);
56 | const dbSections = await createSections(sections, user);
57 | return dbSections;
58 | };
59 |
60 | const createSections = async (sections, user) => {
61 | const updatedSections = await Promise.map(sections, section => {
62 | const newSection = {
63 | title: section.title,
64 | type: section.type,
65 | key: section.key,
66 | UserId: user.id,
67 | };
68 | return updateOrCreate(
69 | models.PlexSection,
70 | {
71 | title: section.title,
72 | UserId: user.id,
73 | },
74 | newSection,
75 | );
76 | }).catch(err => {
77 | logger.error(`createSections ${err}`);
78 | });
79 | return updatedSections;
80 | };
81 |
82 | const importLibraries = async user => {
83 | const sections = await plexApi.getSections(user);
84 | await createSections(sections, user);
85 | return Promise.map(sections, section => {
86 | return importLibrary(section.key, user);
87 | }).catch(err => logger.error(`ImportLibraries ${err}`));
88 | };
89 |
90 | const importLibrary = async (sectionKey, user) => {
91 | const libraryData = await plexApi.getLibraryDataBySection(
92 | {
93 | sectionKey,
94 | },
95 | user,
96 | );
97 | const dbLibraryData = await createLibrary(libraryData, user);
98 | return dbLibraryData;
99 | };
100 |
101 | const createLibrary = async (libraryData, user) => {
102 | const updatedLibrary = await Promise.map(
103 | libraryData,
104 | sectionLibraryData => {
105 | const newSectionLibraryData = {
106 | title: sectionLibraryData.title,
107 | type: sectionLibraryData.type,
108 | views: sectionLibraryData.views,
109 | rating_key: sectionLibraryData.ratingKey,
110 | meta_data_path: sectionLibraryData.key,
111 | UserId: user.id,
112 | summary: sectionLibraryData.summary,
113 | rating: sectionLibraryData.rating,
114 | year: sectionLibraryData.year,
115 | genre: JSON.stringify(sectionLibraryData.Genre),
116 | };
117 | return updateOrCreate(
118 | models.PlexLibrary,
119 | {
120 | UserId: user.id,
121 | title: sectionLibraryData.title,
122 | },
123 | newSectionLibraryData,
124 | );
125 | },
126 | ).catch(err => console.log(err));
127 | return updatedLibrary;
128 | };
129 |
130 | const importMostWatched = async user => {
131 | try {
132 | const sections = await models.PlexSection.findAll({
133 | where: { UserId: user.id },
134 | });
135 |
136 | const sectionKeys = sections.map(section => {
137 | return section.key.toString();
138 | });
139 |
140 | sections.map(section => section.key.toString());
141 | return Promise.map(sectionKeys, sectionKey => {
142 | return importMostWatchedData(sectionKey, user);
143 | }).catch(err => {
144 | console.log(err);
145 | });
146 | } catch (error) {
147 | console.log('caught error', error);
148 | }
149 | };
150 |
151 | const importMostWatchedData = async (sectionKey, user) => {
152 | const mostWatchedData = await plexApi.getMostWatched(
153 | { sectionKey },
154 | user,
155 | );
156 |
157 | const mostWatchedDbData = await updateLibrary(
158 | mostWatchedData,
159 | user,
160 | );
161 | return mostWatchedDbData;
162 | };
163 |
164 | const updateLibrary = async (libraryData, user) => {
165 | const updatedLibrary = await Promise.map(libraryData, data => {
166 | const newData = {
167 | title: data.title,
168 | type: data.type,
169 | views: data.globalViewCount,
170 | rating_key: data.ratingKey,
171 | summary: data.summary,
172 | UserId: user.id,
173 | rating: data.rating,
174 | year: data.year,
175 | genre: JSON.stringify(data.Genre),
176 | };
177 | return updateOrCreate(
178 | models.PlexLibrary,
179 | {
180 | UserId: user.id,
181 | title: data.title,
182 | },
183 | newData,
184 | );
185 | }).catch(err => {
186 | console.log('Unable to import most watched', err);
187 | });
188 | };
189 |
190 | export default {
191 | importSections,
192 | importLibraries,
193 | importMostWatched,
194 | importTvPosters,
195 | };
196 |
--------------------------------------------------------------------------------
/server/services/plex/index.js:
--------------------------------------------------------------------------------
1 | import plexApi from './plexApi';
2 | import importData from './importData';
3 | import auth from './auth';
4 | import models from '../../db/models';
5 | import helpers from '../helpers';
6 | import { Op } from 'sequelize';
7 | import logger from '../../../config/winston';
8 |
9 | const getPlexPin = async (req, res) => {
10 | try {
11 | const pinRes = await auth.getPlexPin(req.user);
12 | const plexPinId = pinRes.pin.id['$t'];
13 | await models.User.update(
14 | { plexPinId },
15 | { where: { email: req.user.email } },
16 | );
17 | const pinCode = pinRes.pin.code;
18 | return res.json(pinCode);
19 | } catch (error) {
20 | console.log('error in auth', error);
21 | return res.status(201).json(error.message);
22 | }
23 | };
24 |
25 | const checkPlexPin = async (req, res) => {
26 | try {
27 | const token = await auth.checkPlexPin(
28 | req.user.plexPinId,
29 | req.user,
30 | );
31 | if (token.nil) {
32 | return res.json(null);
33 | }
34 | console.log('checking', req.user);
35 | await auth.getPlexUrl(token, req.user);
36 | return res.json(token);
37 | } catch (error) {
38 | console.log('error in auth', error);
39 | return res.status(201).json(error.message);
40 | }
41 | };
42 |
43 | const getUsers = (req, res) => {
44 | plexApi
45 | .getUsers(req.user)
46 | .then(users => {
47 | res.json(users);
48 | })
49 | .catch(helpers.handleError(res, getUsers.name));
50 | };
51 |
52 | const getMostWatched = async (req, res) => {
53 | try {
54 | const options = req.query;
55 | const mostWatched = await plexApi.getMostWatched(
56 | options,
57 | req.user,
58 | );
59 | res.json(mostWatched);
60 | } catch (error) {
61 | logger.info(`getMostWatched ${error.stack}`);
62 | res.json(error);
63 | }
64 | };
65 |
66 | const getSections = async (req, res) => {
67 | try {
68 | const sections = await plexApi.getSections(req.user);
69 | res.json(sections);
70 | } catch (error) {
71 | res.json(error);
72 | }
73 | };
74 |
75 | const getLibraryDataBySection = async (req, res) => {
76 | try {
77 | const options = { sectionId: req.params.id };
78 | const sections = await plexApi.getLibraryDataBySection(
79 | options,
80 | req.user,
81 | );
82 | res.json(sections);
83 | } catch (error) {
84 | res.json(error);
85 | }
86 | };
87 |
88 | const importSections = async (req, res) => {
89 | const sections = await importData.importSections(req.user);
90 | res.json(sections);
91 | };
92 |
93 | const importLibraries = async (req, res) => {
94 | const libraries = await importData.importLibraries(req.user);
95 | res.json(libraries);
96 | };
97 |
98 | const importMostWatched = async (req, res) => {
99 | const libraries = await importData.importMostWatched(req.user);
100 | res.json(libraries);
101 | };
102 |
103 | const importAll = async (req, res) => {
104 | logger.info(`Beginning to import all data for ${req.user.email}`);
105 | try {
106 | await importData.importSections(req.user);
107 | await importData.importLibraries(req.user);
108 | await importData.importMostWatched(req.user);
109 | await importData.importTvPosters(req.user);
110 | res.json('Successfully imported/updated data');
111 | } catch (error) {
112 | res.json(error);
113 | }
114 | };
115 |
116 | export default {
117 | getUsers,
118 | getMostWatched,
119 | getSections,
120 | getLibraryDataBySection,
121 | importSections,
122 | importLibraries,
123 | importMostWatched,
124 | importAll,
125 | getPlexPin,
126 | checkPlexPin,
127 | };
128 |
--------------------------------------------------------------------------------
/server/services/plex/plexApi.js:
--------------------------------------------------------------------------------
1 | import config from '../../../config';
2 | import helpers from '../helpers';
3 | import logger from '../../../config/winston';
4 | const getUsers = async function(user) {
5 | try {
6 | const urlParams = getUsersUrlParams(user);
7 | const getUsersUrl = helpers.buildUrl(urlParams);
8 | const response = await helpers.request(getUsersUrl);
9 | return response.MediaContainer.User;
10 | } catch (error) {
11 | return error;
12 | }
13 | };
14 |
15 | const getUsersUrlParams = function(user) {
16 | return {
17 | host: user.plexUrl,
18 | path: '/users',
19 | queryParams: {
20 | 'X-Plex-Token': user.plexToken,
21 | },
22 | };
23 | };
24 |
25 | const getSections = async function(user) {
26 | try {
27 | const urlParams = getSectionsUrlParams(user);
28 | const getSectionsUrl = helpers.buildUrl(urlParams);
29 | const response = await helpers.request(getSectionsUrl);
30 | return response.MediaContainer.Directory;
31 | } catch (error) {
32 | return {
33 | code: error.status,
34 | message: error.statusText,
35 | };
36 | }
37 | };
38 |
39 | const getSectionsUrlParams = function(user) {
40 | return {
41 | host: user.plexUrl,
42 | path: '/library/sections',
43 | queryParams: {
44 | 'X-Plex-Token': user.plexToken,
45 | },
46 | };
47 | };
48 |
49 | const getLibraryDataBySection = async function({ sectionKey }, user) {
50 | try {
51 | const urlParams = getLibraryDataBySectionUrlParams(
52 | sectionKey,
53 | user,
54 | );
55 | const getLibraryDataBySectionUrl = helpers.buildUrl(urlParams);
56 | const response = await helpers.request(
57 | getLibraryDataBySectionUrl,
58 | );
59 | return response.MediaContainer.Metadata;
60 | } catch (error) {
61 | console.log('caught error', error);
62 | return {
63 | code: error.status,
64 | message: error.statusText,
65 | url: error.config.url,
66 | };
67 | }
68 | };
69 |
70 | const getLibraryDataBySectionUrlParams = function(sectionId, user) {
71 | return {
72 | host: user.plexUrl,
73 | path: `/library/sections/${sectionId}/all`,
74 | queryParams: {
75 | 'X-Plex-Token': user.plexToken,
76 | },
77 | };
78 | };
79 |
80 | const getMostWatched = async function(
81 | { accountId, sectionKey, limit = 10 },
82 | user,
83 | ) {
84 | try {
85 | console.log('section key mike --', sectionKey);
86 | const urlParams = mostWatchedUrlParams(
87 | accountId,
88 | sectionKey,
89 | limit,
90 | user,
91 | );
92 | const mostWatchedUrl = helpers.buildUrl(urlParams);
93 | const response = await helpers.request(mostWatchedUrl);
94 | if (response.MediaContainer.Metadata) {
95 | return response.MediaContainer.Metadata;
96 | } else {
97 | return [];
98 | }
99 | } catch (error) {
100 | logger('getMostWatched plexAPI', error);
101 | console.log('getMostWatched plexAPI error', error);
102 | return {
103 | code: error.status,
104 | message: error.statusText,
105 | url: error.config.url,
106 | };
107 | }
108 | };
109 |
110 | const mostWatchedUrlParams = function(
111 | accountId,
112 | sectionKey,
113 | limit = 10,
114 | user,
115 | ) {
116 | return {
117 | host: user.plexUrl,
118 | path: '/library/all/top',
119 | queryParams: {
120 | ...(accountId && { accountId }),
121 | ...(sectionKey && { type: sectionKey }),
122 | ...(limit && { limit }),
123 | 'X-Plex-Token': user.plexToken,
124 | },
125 | };
126 | };
127 |
128 | export default {
129 | getUsers,
130 | getMostWatched,
131 | getSections,
132 | getLibraryDataBySection,
133 | getUsersUrlParams,
134 | getLibraryDataBySectionUrlParams,
135 | getSectionsUrlParams,
136 | };
137 |
--------------------------------------------------------------------------------
/server/services/recommend/index.js:
--------------------------------------------------------------------------------
1 | import models from '../../db/models';
2 | import helpers from '../helpers';
3 | import { Op } from 'sequelize';
4 |
5 | const getMostWatched = async (req, res) => {
6 | try {
7 | const mostWatched = await models.PlexLibrary.findAll({
8 | where: {
9 | UserId: req.user.id,
10 | type: 'show',
11 | views: { [Op.gt]: 0 },
12 | },
13 | });
14 | res.json(mostWatched);
15 | } catch (error) {
16 | res.json(error.message);
17 | }
18 | };
19 |
20 | export default {
21 | getMostWatched,
22 | };
23 |
--------------------------------------------------------------------------------
/server/services/sonarr/index.js:
--------------------------------------------------------------------------------
1 | import sonarrApi from './sonarrApi';
2 | import models from '../../db/models';
3 | import helpers from '../helpers';
4 | import {Op} from 'sequelize';
5 |
6 | const search = async (req, res) => {
7 | const {showName} = req.query;
8 | console.log(showName, req.user.sonarrUrl);
9 | const response = await sonarrApi.search(showName, req.user);
10 | res.json(response);
11 | };
12 |
13 | const addSeries = async (req, res) => {
14 | const {showName} = req.query;
15 | const response = await sonarrApi.addSeries(showName, req.user);
16 | res.json(response);
17 | };
18 |
19 | const getSeries = async (req, res) => {
20 | const response = await sonarrApi.getSeries(req.user);
21 | res.json(response);
22 | };
23 |
24 | export default {
25 | search,
26 | addSeries,
27 | getSeries,
28 | };
29 |
--------------------------------------------------------------------------------
/server/services/sonarr/sonarrApi.js:
--------------------------------------------------------------------------------
1 | import config from '../../../config';
2 | import helpers from '../helpers';
3 | import models from '../../db/models';
4 | import request from 'request-promise';
5 |
6 | const search = async (showName, user) => {
7 | try {
8 | const params = {
9 | baseUrl: user.sonarrUrl,
10 | uri: '/api/series/lookup',
11 | headers: {'x-api-key': user.sonarrApiKey},
12 | qs: {
13 | term: showName,
14 | },
15 | };
16 | const res = await request(params);
17 | const jsonData = await JSON.parse(res);
18 | return jsonData[0];
19 | } catch (error) {
20 | helpers.handleError(error, 'searchSonarr');
21 | }
22 | };
23 |
24 | const addSeries = async (showName, user) => {
25 | try {
26 | const body = await search(showName, user);
27 | body.profileId = 1;
28 | const rootFolder = await getRootFolder(user);
29 | console.log(rootFolder);
30 | body.rootFolderPath = JSON.parse(rootFolder)[0].path;
31 | const params = {
32 | baseUrl: user.sonarrUrl,
33 | uri: '/api/series',
34 | headers: {'x-api-key': user.sonarrApiKey},
35 | body: body,
36 | json: true,
37 | };
38 |
39 | const res = await request.post(params);
40 | return res;
41 | } catch (error) {
42 | console.log(error);
43 | return error.errorMessage;
44 | }
45 | };
46 |
47 | const getRootFolder = async user => {
48 | const params = {
49 | uri: user.sonarrUrl + '/api/rootfolder',
50 | headers: {'x-api-key': user.sonarrApiKey},
51 | };
52 | const res = await request(params);
53 | return res;
54 | };
55 |
56 | const getSeries = async user => {
57 | const params = {
58 | uri: user.sonarrUrl + '/api/series',
59 | headers: {'x-api-key': user.sonarrApiKey},
60 | };
61 | const res = await request(params);
62 | return res;
63 | };
64 |
65 | export default {search, addSeries, getSeries};
66 |
--------------------------------------------------------------------------------
/server/services/tdaw/index.js:
--------------------------------------------------------------------------------
1 | import tdawApi from './tdawApi';
2 | import models from '../../db/models';
3 | import helpers from '../helpers';
4 | import logger from '../../../config/winston';
5 |
6 | const similarMedia = async (req, res) => {
7 | try {
8 | const { showName } = req.query;
9 | logger.info(`Show name for similarMedia ${showName}`);
10 | const formattedShowName = showName.replace(/ *\([^)]*\) */g, '');
11 | logger.info(
12 | `show name for tdaw api similar media ${formattedShowName}`,
13 | );
14 | const media = 'shows';
15 | const response = await tdawApi.similarMedia(
16 | req,
17 | formattedShowName.replace(/[{()}]/g, ''),
18 | media,
19 | );
20 | console.log('tdaw response test--', response);
21 | res.json(response);
22 | } catch (error) {
23 | helpers.handleError(res, tdawApi.name);
24 | }
25 | };
26 |
27 | const mostWatched = async (req, res) => {
28 | console.log('express-request-object---', req);
29 | const response = await tdawApi.mostWatched();
30 | console.log(response);
31 | res.json(response);
32 | };
33 |
34 | const qlooMedia = async (req, res) => {
35 | try {
36 | const { mediaName, mediaType } = req.query;
37 | const mediaId = await tdawApi.qlooMediaId(mediaName, mediaType);
38 | const response = await tdawApi.qlooMedia(mediaId, mediaType);
39 | res.json(response);
40 | } catch (error) {
41 | helpers.handleError(res, tdawApi.name);
42 | }
43 | };
44 |
45 | export default {
46 | similarMedia,
47 | mostWatched,
48 | qlooMedia,
49 | };
50 |
--------------------------------------------------------------------------------
/server/services/tdaw/tdawApi.js:
--------------------------------------------------------------------------------
1 | import config from '../../../config';
2 | import helpers from '../helpers';
3 | import models from '../../db/models';
4 | import movieDbApi from '../moviedb/movieDbApi';
5 | import logger from '../../../config/winston';
6 |
7 | const tdawMediaUrl = function(mediaName, mediaType) {
8 | return {
9 | host: config.tdaw.tdawApiUrl,
10 | queryParams: {
11 | q: mediaName,
12 | k: config.tdaw.token,
13 | info: 1,
14 | type: mediaType,
15 | },
16 | };
17 | };
18 |
19 | const similarMedia = async function(req, mediaName, mediaType) {
20 | try {
21 | const formattedShowName = mediaName.replace(/ *\([^)]*\) */g, '');
22 | logger.info(
23 | `Show name for tdaw similar req ${formattedShowName}`,
24 | );
25 | const urlParams = tdawMediaUrl(formattedShowName, mediaType);
26 | const mediaUrl = helpers.buildUrl(urlParams);
27 | const similarResponse = await helpers.request(mediaUrl);
28 | console.log('TCL: tdaw -> similarResponse', similarResponse);
29 |
30 | const jsonLibrary = await models.PlexLibrary.findAll({
31 | userId: req.user.id,
32 | type: 'show',
33 | });
34 |
35 | // Use Sonarr list instead
36 | const libraryTitles = jsonLibrary.map(show =>
37 | show.title.toLowerCase(),
38 | );
39 |
40 | const filteredResponse = await similarResponse.filter(
41 | show => !libraryTitles.includes(show.Name.toLowerCase()),
42 | );
43 |
44 | console.log('filteredResponse ---', filteredResponse);
45 | const movieDbInfo = await getShowData(filteredResponse);
46 | console.log('movieDbInfo', movieDbInfo);
47 | return movieDbInfo;
48 | } catch (error) {
49 | return {
50 | code: error.status,
51 | message: error.statusText,
52 | url: error.config.url,
53 | };
54 | }
55 | };
56 |
57 | const getShowData = async filteredResponse => {
58 | const showData = await Promise.all(
59 | filteredResponse.map(show => movieDbApi.searchTv(show.Name)),
60 | );
61 | console.log('mapped show data', showData);
62 | return showData.filter(obj => obj);
63 | };
64 |
65 | const qlooMediaId = async (mediaName, mediaType) => {
66 | const params = {
67 | host:
68 | 'https://qsz08t9vtl.execute-api.us-east-1.amazonaws.com/production/search',
69 | queryParams: { query: mediaName },
70 | };
71 |
72 | const formattedMediaType = mediaTypeMapping()[mediaType];
73 |
74 | const response = await helpers.request(helpers.buildUrl(params));
75 |
76 | const filteredResponse = response.results.filter(results =>
77 | results.categories.includes(formattedMediaType),
78 | );
79 |
80 | return filteredResponse[0].id;
81 | };
82 |
83 | const mediaTypeMapping = () => {
84 | return { tv: 'tv/shows', movie: 'film/movies' };
85 | };
86 |
87 | const qlooMedia = async (mediaId, mediaType) => {
88 | // recs?category=tv/shows&sample=70AB59C0-789F-4E11-B72D-FE09BF76901E&prioritize_indomain=False
89 | const formattedMediaType = mediaTypeMapping()[mediaType];
90 | const params = {
91 | host:
92 | 'https://qsz08t9vtl.execute-api.us-east-1.amazonaws.com/production/recs',
93 | queryParams: {
94 | category: formattedMediaType,
95 | sample: mediaId,
96 | prioritize_indomain: 'False',
97 | },
98 | };
99 |
100 | const response = await helpers.request(helpers.buildUrl(params));
101 | console.log(response);
102 | return response;
103 | };
104 | export default {
105 | similarMedia,
106 | tdawMediaUrl,
107 | qlooMediaId,
108 | qlooMedia,
109 | };
110 |
--------------------------------------------------------------------------------
/static.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": "build/",
3 | "clean_urls": false,
4 | "routes": {
5 | "/**": "index.html"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/test/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb-base",
3 | "env": {
4 | "node": true,
5 | "mocha": true
6 | },
7 | "rules": {
8 | "no-unused-vars": 0,
9 | "no-unused-expressions": 0,
10 | "import/no-extraneous-dependencies": 0
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/test/mocks/authResponse.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | mjrode
82 | michaelrode44@gmail.com
83 | 2014-01-03 03:05:51 UTC
84 | testPlexApiToken
85 |
--------------------------------------------------------------------------------
/test/mocks/error.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Bad Request
4 |
5 |
6 | 400 Bad Request
7 |
8 |
9 |
--------------------------------------------------------------------------------
/test/mocks/getUsersResponse.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/test/mocks/plexPinResponse.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 1742227353
4 | CX56
5 | 2019-09-09T05:04:56Z
6 |
7 | 0e865e70-332e-11e9-afcf-e35ae68216f9
8 | false
9 |
10 |
11 |
--------------------------------------------------------------------------------
/test/nocks.js:
--------------------------------------------------------------------------------
1 | import nock from 'nock';
2 | import plexResponses from './mocks/plexResponses';
3 | import tdawResponses from './mocks/tdawResponses';
4 |
5 | const usersResponse = `${__dirname}/mocks/getUsersResponse.xml`;
6 | const authResponse = `${__dirname}/mocks/authResponse.xml`;
7 | const plexPinResponse = `${__dirname}/mocks/plexPinResponse.xml`;
8 | const invalidRequestResponse = `${__dirname}/mocks/error.html`;
9 |
10 | export const plexSections = () => {
11 | nock('https://plex.mjrflix.com')
12 | .get('/library/sections?X-Plex-Token=testPlexApiToken')
13 | .reply(200, plexResponses.sectionsRaw, {
14 | 'Content-Type': 'text/json',
15 | });
16 | };
17 |
18 | export const plexLibrary = () =>
19 | nock('https://plex.mjrflix.com')
20 | .persist()
21 | .get(url => url.includes('/library/sections/3'))
22 | .reply(200, plexResponses.getLibraryDataBySectionRaw, {
23 | 'Content-Type': 'text/json',
24 | });
25 |
26 | export const plexUsers = () => {
27 | nock('https://plex.tv')
28 | .get('/api/users?X-Plex-Token=testPlexApiToken')
29 | .replyWithFile(200, usersResponse, {
30 | 'Content-Type': 'text/xml',
31 | });
32 | };
33 |
34 | export const mostWatched = () => {
35 | nock('https://plex.mjrflix.com')
36 | .persist()
37 | .get(uri => uri.includes('/library/all/top?type='))
38 | .reply(200, plexResponses.mostWatchedRawTV, {
39 | 'Content-Type': 'text/json',
40 | });
41 | };
42 |
43 | export const newGirlTdaw = () => {
44 | nock('https://tastedive.com/api/similar')
45 | .get(uri => uri.includes('New'))
46 | .reply(200, tdawResponses.newGirl, {
47 | 'Content-Type': 'text/json',
48 | });
49 | };
50 |
51 | export const mostWatchedByAccount = () => {
52 | nock('https://plex.mjrflix.com')
53 | .get(
54 | '/library/all/top?accountId=22099864&type=2&limit=10&X-Plex-Token=testPlexApiToken',
55 | )
56 | .reply(200, plexResponses.mostWatchedByAccountRaw, {
57 | 'Content-Type': 'text/json',
58 | });
59 | };
60 |
61 | export const auth = () => {
62 | nock('https://plex.tv')
63 | .post(uri => uri.includes('/users/sign_in.xml'))
64 | .replyWithFile(200, authResponse, {
65 | 'Content-Type': 'text/xml',
66 | });
67 | };
68 |
69 | export const plexPin = () => {
70 | nock('https://plex.tv')
71 | .post(uri => uri.includes('/pins.xml'))
72 | .replyWithFile(200, plexPinResponse, {
73 | 'Content-Type': 'text/xml',
74 | });
75 | };
76 |
77 | export const invalidRequest = () => {
78 | nock('https://plex.mjrflix.com')
79 | .get(uri => uri.includes('users'))
80 | .replyWithFile(200, invalidRequestResponse, {
81 | 'Content-Type': 'text/xml',
82 | });
83 | };
84 |
--------------------------------------------------------------------------------
/test/server/controllers/auth.controller.test.js:
--------------------------------------------------------------------------------
1 | import chai from 'chai';
2 | import sinon from 'sinon';
3 | import passport from 'passport';
4 | import tk from 'timekeeper';
5 | import * as nocks from '../../nocks';
6 | import app from '../../../index';
7 | import { seed, truncate } from '../../../server/db/scripts';
8 | import models from './../../../server/db/models';
9 | const bCrypt = require('bcrypt-nodejs');
10 |
11 | var expect = require('chai').expect;
12 |
13 | const generateHash = password => {
14 | return bCrypt.hashSync(password, bCrypt.genSaltSync(8), null);
15 | };
16 |
17 | const fetchUserAndFormat = email => {
18 | return models.User.findOne({
19 | where: { email },
20 | }).then(user => {
21 | const formattedUser = formatDbResponse(user);
22 | delete formattedUser.id;
23 | return formattedUser;
24 | });
25 | };
26 |
27 | const formatDbResponse = record => {
28 | const user = JSON.parse(
29 | JSON.stringify(record.get({ plain: true, raw: true })),
30 | );
31 | delete user.id;
32 | return user;
33 | };
34 |
35 | const tokenResponse = {
36 | access_token: 'at-1234',
37 | expires_in: 3600,
38 | };
39 |
40 | const googleProfile = email => {
41 | return {
42 | id: '103913097386807680151',
43 | displayName: 'Michael Rode',
44 | name: { familyName: 'Rode', givenName: 'Michael' },
45 | emails: [{ value: email, verified: true }],
46 | provider: 'google',
47 | };
48 | };
49 |
50 | const setMockGoogleStrategy = email => {
51 | let strategy = passport._strategies['google'];
52 |
53 | strategy._token_response = tokenResponse;
54 | strategy._profile = googleProfile(email);
55 | };
56 |
57 | describe('auth.controller', () => {
58 | beforeEach(async () => {
59 | await truncate('User');
60 | await truncate('PlexLibrary');
61 | await truncate('PlexSection');
62 | });
63 | before(async () => {
64 | tk.freeze(new Date(1330688329321));
65 | await truncate('User');
66 | await truncate('PlexLibrary');
67 | await truncate('PlexSection');
68 | await seed('User', 'Users');
69 | await seed('PlexSection');
70 | await seed('PlexSection', 'PlexSections');
71 | await seed('PlexLibrary');
72 | });
73 |
74 | after(() => {
75 | tk.reset();
76 | });
77 |
78 | describe('when there is no user present in the session', () => {
79 | describe('a request is made to GET /api/auth/current_user', () => {
80 | it('should return null', done => {
81 | chai
82 | .request(app)
83 | .get('/api/auth/current_user')
84 | .end((err, res) => {
85 | res.should.have.status(200);
86 | res.body.should.be.empty;
87 | done();
88 | });
89 | });
90 | });
91 |
92 | describe('a request is made to GET /api/auth/google', () => {
93 | describe('and the user has not previously registered', () => {
94 | it('should not find the current user in the database before auth', async () => {
95 | const dbUser = await models.User.findOne({
96 | where: { email: 'michaelrode44@gmail.com' },
97 | });
98 | expect(dbUser).to.be.null;
99 | const usersCount = await models.User.count();
100 | expect(usersCount).to.equal(0);
101 | });
102 |
103 | describe('When a user successfully auths with google', () => {
104 | it('should create a new user record in the database and return the user record', async () => {
105 | const usersCountBefore = await models.User.count();
106 | expect(usersCountBefore).to.equal(0);
107 |
108 | setMockGoogleStrategy('michaelrode44@gmail.com');
109 |
110 | const res = await chai
111 | .request(app)
112 | .get('/api/auth/google')
113 | .redirects(1);
114 |
115 | res.should.have.status(302);
116 | expect(res.headers.location).to.equal(
117 | '/plex-pin?email=michaelrode44@gmail.com',
118 | );
119 |
120 | const usersCountAfter = await models.User.count();
121 | expect(usersCountAfter).to.equal(1);
122 | });
123 | });
124 | });
125 | });
126 | describe('when the user already exists in the database', () => {
127 | it('user count should not change', async () => {
128 | await models.User.create({
129 | email: 'michaelrode44@gmail.com',
130 | password: 'password',
131 | });
132 | const usersCountBefore = await models.User.count();
133 | expect(usersCountBefore).to.equal(1);
134 |
135 | setMockGoogleStrategy('michaelrode44@gmail.com');
136 |
137 | const res = await chai
138 | .request(app)
139 | .get('/api/auth/google')
140 | .redirects(1);
141 |
142 | res.should.have.status(302);
143 | expect(res.headers.location).to.equal(
144 | '/plex-pin?email=michaelrode44@gmail.com',
145 | );
146 |
147 | const usersCountAfter = await models.User.count();
148 | expect(usersCountAfter).to.equal(1);
149 | });
150 | });
151 | });
152 | describe('POST /api/auth/login', async () => {
153 | describe('When a user successfully logs in with local strategy', () => {
154 | it('set the current user in session', async () => {
155 | const password = generateHash('password');
156 | await models.User.create({
157 | email: 'michaelrode44@gmail.com',
158 | password,
159 | });
160 | const usersCountBefore = await models.User.count();
161 | expect(usersCountBefore).to.equal(1);
162 |
163 | const res = await chai
164 | .request(app)
165 | .post('/api/auth/login')
166 | .send({
167 | email: 'michaelrode44@gmail.com',
168 | password: 'password',
169 | })
170 | .redirects(1);
171 | res.should.have.status(200);
172 | expect(res.body).to.deep.equal({
173 | email: 'michaelrode44@gmail.com',
174 | });
175 |
176 | const usersCountAfter = await models.User.count();
177 | expect(usersCountAfter).to.equal(1);
178 | });
179 | });
180 | describe('when a user fails to auth with local strategy', () => {
181 | it('should return missing credentials error when missing required params', done => {
182 | chai
183 | .request(app)
184 | .post('/api/auth/login')
185 | .send({
186 | email: 'mike.rodde@gmail.com',
187 | })
188 | .end((err, res) => {
189 | res.should.have.status(200);
190 | res.body.message.should.equal('Missing credentials');
191 | done();
192 | });
193 | });
194 |
195 | it('should return invalid password error when given the wrong password', async () => {
196 | const password = generateHash('password');
197 | await models.User.create({
198 | email: 'michaelrode44@gmail.com',
199 | password,
200 | });
201 | const usersCountBefore = await models.User.count();
202 | expect(usersCountBefore).to.equal(1);
203 |
204 | const res = await chai
205 | .request(app)
206 | .post('/api/auth/login')
207 | .send({
208 | email: 'michaelrode44@gmail.com',
209 | password: 'pass',
210 | });
211 | res.should.have.status(200);
212 | res.body.message.should.equal('Incorrect password.');
213 | });
214 | });
215 | });
216 | describe('POST /api/auth/sign-up', async () => {
217 | it('should not find the current user in the database before auth', async () => {
218 | const dbUser = await models.User.findOne({
219 | where: { email: 'michaelrode@gmail.com' },
220 | });
221 | expect(dbUser).to.be.null;
222 | });
223 |
224 | describe('When a user successfully signs up with local strategy', () => {
225 | describe('and the user has not previously registered', () => {
226 | it('should create a new user record in the database and return the user record', async () => {
227 | const usersCountBefore = await models.User.count();
228 | expect(usersCountBefore).to.equal(0);
229 | const res = await chai
230 | .request(app)
231 | .post('/api/auth/sign-up')
232 | .send({
233 | email: 'mike.rodde@gmail.com',
234 | password: 'password',
235 | })
236 | .redirects(1);
237 | res.should.have.status(200);
238 | expect(res.body).to.deep.equal({
239 | email: 'mike.rodde@gmail.com',
240 | });
241 |
242 | const usersCountAfter = await models.User.count();
243 | expect(usersCountAfter).to.equal(1);
244 | });
245 | });
246 | describe('when a user fails to auth with local strategy', () => {
247 | it('should return missing credentials error when missing required params', done => {
248 | chai
249 | .request(app)
250 | .post('/api/auth/sign-up')
251 | .send({
252 | email: 'mike.rodde@gmail.com',
253 | })
254 | .end((err, res) => {
255 | res.should.have.status(200);
256 | res.body.message.should.equal('Missing credentials');
257 | done();
258 | });
259 | });
260 | });
261 | });
262 | });
263 | });
264 |
--------------------------------------------------------------------------------
/test/server/controllers/plex.controller.test.js:
--------------------------------------------------------------------------------
1 | import chai from 'chai';
2 | import chaiHttp from 'chai-http';
3 | import sinon from 'sinon';
4 | import passport from 'passport';
5 | import tk from 'timekeeper';
6 | import * as nocks from '../../nocks';
7 | import app from '../../../index';
8 | import { seed, truncate } from '../../../server/db/scripts';
9 | import models from './../../../server/db/models';
10 | import * as testHelpers from '../helpers';
11 | const bCrypt = require('bcrypt-nodejs');
12 |
13 | var expect = require('chai').expect;
14 |
15 | const generateHash = password => {
16 | return bCrypt.hashSync(password, bCrypt.genSaltSync(8), null);
17 | };
18 |
19 | const createUserWithNoPin = () => {
20 | return models.User.create({
21 | email: 'testuser@email.com',
22 | password: generateHash('password'),
23 | });
24 | };
25 |
26 | describe.only('plex.controller', () => {
27 | beforeEach(async () => {
28 | await truncate('User');
29 | await truncate('PlexLibrary');
30 | await truncate('PlexSection');
31 | });
32 | describe('a request is made to GET /api/plex/plex-pin', () => {
33 | describe('there is a valid user in the session', () => {
34 | it('should return a plexPinId', async () => {
35 | nocks.plexPin();
36 | await createUserWithNoPin();
37 | const authorizedAgent = await testHelpers.authorizedAgent(
38 | 'testuser@email.com',
39 | );
40 |
41 | const res = await authorizedAgent.get('/api/plex/plex-pin');
42 |
43 | res.should.have.status(200);
44 | res.body.should.equal('CX56');
45 | await authorizedAgent.close();
46 | });
47 | });
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/test/server/helpers.js:
--------------------------------------------------------------------------------
1 | import chai from 'chai';
2 | import chaiHttp from 'chai-http';
3 | chai.use(chaiHttp);
4 | import app from '../../index';
5 |
6 | export const authorizedAgent = async email => {
7 | var agent = chai.request.agent(app);
8 | const res = await agent
9 | .post('/api/auth/login')
10 | .send({ email: 'testuser@email.com', password: 'password' })
11 | .redirects(1);
12 |
13 | return agent;
14 | };
15 |
--------------------------------------------------------------------------------
/test/server/services/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/test/server/services/.DS_Store
--------------------------------------------------------------------------------
/test/server/services/plex/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mjrode/WhatToWatch/756b930ed58ca66ec0ed0118ff0235504d7d62b6/test/server/services/plex/.DS_Store
--------------------------------------------------------------------------------
/test/server/services/plex/auth.test.js:
--------------------------------------------------------------------------------
1 | import chai from 'chai';
2 | import * as nocks from '../../../nocks';
3 | import app from '../../../../index';
4 |
5 | describe('Users', () => {
6 | describe('GET /api/plex/auth', async () => {
7 | it('should get plex auth token', done => {
8 | nocks.auth();
9 |
10 | chai
11 | .request(app)
12 | .get('/api/plex/token')
13 | .query({
14 | username: 'username',
15 | password: 'password',
16 | plexUrl: 'plexserver.com',
17 | })
18 | .end((err, res) => {
19 | res.should.have.status(200);
20 | res.body.should.equal('testPlexApiToken');
21 | done();
22 | });
23 | });
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/test/server/services/plex/importData.test.js:
--------------------------------------------------------------------------------
1 | import chai from 'chai';
2 | import nock from 'nock';
3 | import app from '../../../../index';
4 | import importData from '../../../../server/services/plex/importData';
5 | import models from '../../../../server/db/models';
6 | import { seed, truncate } from '../../../../server/db/scripts';
7 | import * as nocks from '../../../nocks';
8 |
9 | describe('ImportData', () => {
10 | before(async () => {
11 | await truncate('User');
12 | await truncate('PlexLibrary');
13 | await truncate('PlexSection');
14 | await seed('User', 'Users');
15 | await seed('PlexSection');
16 | await seed('PlexSection', 'PlexSections');
17 | await seed('PlexLibrary');
18 | });
19 | after(() => {});
20 |
21 | describe('GET /plex/import/sections', () => {
22 | it('should find and store sections in the database for the user that is passed in', async () => {
23 | nocks.plexSections();
24 |
25 | const user = await models.User.findOne({
26 | where: { googleId: '101111197386111111151' },
27 | });
28 |
29 | const sectionsBeforeUpdate = await models.PlexSection.findOne({
30 | where: {
31 | UserId: 1,
32 | type: 'show',
33 | title: 'TV Shows',
34 | },
35 | });
36 |
37 | sectionsBeforeUpdate.dataValues.title.should.eq('TV Shows');
38 | sectionsBeforeUpdate.dataValues.type.should.eq('show');
39 | sectionsBeforeUpdate.dataValues.key.should.eq(4);
40 |
41 | await importData.importSections(user);
42 | const sections = await models.PlexSection.findAll();
43 |
44 | const movies = sections.filter(
45 | data =>
46 | data.dataValues.title === 'Movies' &&
47 | data.dataValues.UserId === user.id,
48 | );
49 |
50 | movies[0].dataValues.title.should.eq('Movies');
51 | movies[0].dataValues.type.should.eq('movie User 1');
52 | movies[0].dataValues.key.should.eq(2);
53 |
54 | const tvShows = sections.filter(
55 | data =>
56 | data.dataValues.title === 'TV Shows' &&
57 | data.dataValues.UserId === user.id,
58 | );
59 | tvShows[0].dataValues.title.should.eq('TV Shows');
60 | tvShows[0].dataValues.type.should.eq('show User 1');
61 | tvShows[0].dataValues.key.should.eq(3);
62 |
63 | sections.should.be.length(4);
64 | });
65 | });
66 |
67 | describe('GET /plex/import/libraries', () => {
68 | it('should find and store libraries in the database for the user that is passed in', async () => {
69 | const libraryBeforeImport = await models.PlexLibrary.findAll();
70 | libraryBeforeImport.should.be.length(10);
71 |
72 | const user = await models.User.findOne({
73 | where: { googleId: '101111197386111111151' },
74 | });
75 |
76 | const movieBeforeUpdate = await models.PlexLibrary.findOne({
77 | where: {
78 | title: 'Big Time in Hollywood, FL',
79 | UserId: 1,
80 | },
81 | });
82 |
83 | movieBeforeUpdate.dataValues.title.should.eq(
84 | 'Big Time in Hollywood, FL',
85 | );
86 | movieBeforeUpdate.dataValues.type.should.eq(
87 | 'show before import',
88 | );
89 |
90 | const testUserLibrary = await models.PlexLibrary.findAll({
91 | where: { UserId: 999 },
92 | });
93 |
94 | testUserLibrary.should.be.length(9);
95 |
96 | nocks.plexSections();
97 | nocks.plexLibrary();
98 | await importData.importLibraries(user);
99 | const library = await models.PlexLibrary.findAll();
100 | library.should.be.length(65);
101 |
102 | nocks.plexSections();
103 | nocks.plexLibrary();
104 | await importData.importLibraries(user);
105 | const librarySecondRequest = await models.PlexLibrary.findAll();
106 | librarySecondRequest.should.be.length(65);
107 |
108 | const movieAfterUpdate = await models.PlexLibrary.findOne({
109 | where: {
110 | title: 'Big Time in Hollywood, FL',
111 | UserId: 1,
112 | },
113 | });
114 |
115 | movieAfterUpdate.dataValues.title.should.eq(
116 | 'Big Time in Hollywood, FL',
117 | );
118 | movieAfterUpdate.dataValues.type.should.eq('show');
119 |
120 | const testUserLibraryAfterImport = await models.PlexLibrary.findAll(
121 | {
122 | where: { UserId: 999 },
123 | },
124 | );
125 |
126 | testUserLibraryAfterImport.should.be.length(9);
127 | });
128 | });
129 |
130 | describe('GET /plex/import/most-watched', () => {
131 | it('should find and store libraries in the database', async () => {
132 | const user = await models.User.findOne({
133 | where: { googleId: '101111197386111111151' },
134 | });
135 |
136 | nocks.plexSections();
137 | nocks.plexLibrary();
138 | await importData.importLibraries(user);
139 | const library = await models.PlexLibrary.findAll();
140 | library.should.be.length(65);
141 |
142 | nocks.mostWatched();
143 | await importData.importMostWatched(user);
144 | const newGirl = await models.PlexLibrary.findOne({
145 | where: { UserId: user.id, title: 'New Girl' },
146 | });
147 | newGirl.dataValues.views.should.eq(74);
148 | library.should.be.length(65);
149 | });
150 | });
151 | });
152 |
--------------------------------------------------------------------------------
/test/server/services/plex/index.test.js:
--------------------------------------------------------------------------------
1 | import chai from 'chai';
2 | import chaiHttp from 'chai-http';
3 | import * as nocks from '../../../nocks';
4 | import responses from '../../../mocks/plexResponses';
5 | import app from '../../../../index';
6 |
7 | chai.use(chaiHttp);
8 | const should = chai.should();
9 |
10 | describe('Users', () => {
11 | describe('GET /api/plex/users', async () => {
12 | it('should get all plex users', done => {
13 | nocks.plexUsers();
14 |
15 | chai
16 | .request(app)
17 | .get('/api/plex/users')
18 | .end((err, res) => {
19 | res.should.have.status(200);
20 | res.body.should.be.a('array');
21 | res.body.should.deep.equal(responses.getUsersParsed);
22 | done();
23 | });
24 | });
25 |
26 | it('should handle request error', done => {
27 | chai
28 | .request(app)
29 | .get('/api/plex/users')
30 | .end((err, res) => {
31 | res.should.have.status(500);
32 | res.body.message.should.equal('An unknown error occurred.');
33 | done();
34 | });
35 | });
36 | });
37 | });
38 |
39 | describe('Most Watched', () => {
40 | describe('GET plex/most-watched?:sectionKey', async () => {
41 | it('should return most watched history', done => {
42 | nocks.mostWatched();
43 |
44 | chai
45 | .request(app)
46 | .get('/api/plex/most-watched?sectionKey=2')
47 | .end((err, res) => {
48 | res.should.have.status(200);
49 | res.body.should.be.a('array');
50 | res.body.should.deep.equal(responses.mostWatchedParsedTV);
51 | done();
52 | });
53 | });
54 | });
55 |
56 | describe('GET plex/most-watched?:accountId&:sectionKey', async () => {
57 | it('should return most watched history per account', done => {
58 | nocks.mostWatchedByAccount();
59 |
60 | chai
61 | .request(app)
62 | .get('/api/plex/most-watched?accountId=22099864§ionKey=2')
63 | .end((err, res) => {
64 | res.should.have.status(200);
65 | res.body.should.be.a('array');
66 | res.body.should.deep.equal(responses.mostWatchedByAccountParsed);
67 | done();
68 | });
69 | });
70 | });
71 | });
72 |
73 | describe('Sections', () => {
74 | describe('GET plex/sections', async () => {
75 | it('should get sections', done => {
76 | nocks.plexSections();
77 | chai
78 | .request(app)
79 | .get('/api/plex/sections')
80 | .end((err, res) => {
81 | res.should.have.status(200);
82 | res.body.should.be.a('array');
83 | res.body.should.deep.equal(responses.sectionsParsed);
84 | done();
85 | });
86 | });
87 | });
88 | });
89 |
90 | describe('Library Data', () => {
91 | describe('GET plex/library?sectionId=3', async () => {
92 | it('should fetch library', done => {
93 | nocks.plexLibrary();
94 | chai
95 | .request(app)
96 | .get('/api/plex/library/3')
97 | .end((err, res) => {
98 | res.should.have.status(200);
99 | res.body.should.be.a('array');
100 | res.body.should.deep.equal(
101 | responses.getLibraryDataBySectionRaw.MediaContainer.Metadata,
102 | );
103 | done();
104 | });
105 | });
106 | });
107 |
108 | describe('GET plex/library?sectionId=3', async () => {
109 | it('should return error upon failure', done => {
110 | nocks.invalidRequest();
111 | chai
112 | .request(app)
113 | .get('/api/users?X-Plex-Token')
114 | .end((err, res) => {
115 | res.should.have.status(404);
116 | res.text.should.equal('Page Not Found');
117 | done();
118 | });
119 | });
120 | });
121 | });
122 |
--------------------------------------------------------------------------------
/test/server/services/plex/plexApi.test.js:
--------------------------------------------------------------------------------
1 | import plexResponses from '../../../mocks/plexResponses';
2 | import plexApi from '../../../../server/services/plex/plexApi';
3 | import helpers from '../../../../server/services/helpers';
4 | import * as nocks from '../../../nocks';
5 |
6 | describe('plexApi', () => {
7 | it('return url params object', () => {
8 | const result = plexApi.getUsersUrlParams();
9 | result.should.deep.equal({
10 | host: 'https://plex.tv/api',
11 | path: '/users',
12 | queryParams: {
13 | 'X-Plex-Token': 'testPlexApiToken',
14 | },
15 | });
16 | });
17 |
18 | it('returns url', () => {
19 | const urlParams = plexApi.getUsersUrlParams();
20 | const url = helpers.buildUrl(urlParams);
21 | url.should.equal(
22 | 'https://plex.tv/api/users?X-Plex-Token=testPlexApiToken',
23 | );
24 | });
25 |
26 | it('handles error when building url', () => {
27 | const urlParams = 'invalid params';
28 | const url = helpers.buildUrl(urlParams);
29 | url.message.should.equal('Invalid urlParams: invalid params');
30 | });
31 |
32 | it('returns users', async () => {
33 | nocks.plexUsers();
34 |
35 | const urlParams = plexApi.getUsersUrlParams();
36 | const url = helpers.buildUrl(urlParams);
37 | const result = await helpers.request(url);
38 | result.should.deep.equal(plexResponses.getUsersRaw);
39 | });
40 |
41 | it('returns users using getUsers', async () => {
42 | nocks.plexUsers();
43 |
44 | const result = await plexApi.getUsers();
45 | result.should.deep.equal(plexResponses.getUsersParsed);
46 | });
47 |
48 | it('returns library data by sectionId', async () => {
49 | nocks.plexLibrary();
50 | const result = await plexApi.getLibraryDataBySection({
51 | sectionId: 2,
52 | });
53 | result.should.deep.equal(
54 | plexResponses.getLibraryDataBySectionRaw.MediaContainer
55 | .Metadata,
56 | );
57 | });
58 |
59 | // it('handles error if passed incorrect parameters', async (done) => {
60 | // try {
61 | // await plexApi.getLibraryDataBySection('incorrect param');
62 | // } catch (error) {
63 | // console.log('CAUGHTEM', error);
64 | // error.code.should.equal(401);
65 | // error.message.should.equal('Unauthorized');
66 | // error.code.should.equal(
67 | // 'https://plex.mjrflix.com/library/sections/undefined/all?X-Plex-Token=testPlexApiToken',
68 | // );
69 | // }
70 | // });
71 | });
72 |
--------------------------------------------------------------------------------
/test/server/services/tdaw/tdawApi.test.js:
--------------------------------------------------------------------------------
1 | import tdawResponses from '../../../mocks/tdawResponses';
2 | import tdawApi from '../../../../server/services/tdaw/tdawApi';
3 | import helpers from '../../../../server/services/helpers';
4 | import * as nocks from '../../../nocks';
5 |
6 | describe('tdawApi', () => {
7 | it('return tdaw url object', () => {
8 | const result = tdawApi.tdawMediaUrl('New Girl', 'show');
9 | result.should.deep.equal({
10 | host: 'https://tastedive.com/api/similar',
11 | queryParams: {
12 | mediaType: 'show',
13 | info: 1,
14 | k: 'testTdawToken',
15 | q: 'New Girl',
16 | },
17 | });
18 | });
19 |
20 | it('returns url', () => {
21 | const urlParams = tdawApi.tdawMediaUrl('New Girl', 'show');
22 | const url = helpers.buildUrl(urlParams);
23 | url.should.equal(
24 | 'https://tastedive.com/api/similar?q=New%20Girl&k=testTdawToken&info=1&mediaType=show',
25 | );
26 | });
27 |
28 | it('returns similar shows to new girl', async () => {
29 | nocks.newGirlTdaw();
30 |
31 | const urlParams = tdawApi.tdawMediaUrl('New Girl', 'show');
32 | const url = helpers.buildUrl(urlParams);
33 | const result = await helpers.request(url);
34 | result.should.deep.equal(tdawResponses.newGirl.Similar.Results);
35 | });
36 | });
37 |
--------------------------------------------------------------------------------