├── codecov.yml ├── .dockerignore ├── .gitignore ├── requirements.txt ├── test ├── __init__.py ├── config.json ├── BaseTestClass.py ├── test_shows.py ├── test_login.py ├── TestUtil.py ├── test_episode.py └── test_single_show.py ├── sonar-project.properties ├── config.json ├── src ├── etc │ ├── ErrorHandlers.py │ ├── Config.py │ └── Utils.py ├── endpoints_processors │ ├── Login.py │ └── Shows.py └── main.py ├── Dockerfile ├── Makefile ├── .github └── workflows │ └── tests.yml ├── README.md └── LICENSE /codecov.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .vscode 3 | **/__pycache__ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | env/ 3 | *.pyc 4 | .coverage 5 | .vscode/ 6 | .venv -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask == 2.0.0 2 | beautifulsoup4 == 4.9.1 3 | requests == 2.23.0 4 | coverage 5 | Werkzeug==2.3.7 -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 4 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | # must be unique in a given SonarQube instance 2 | sonar.projectKey=tvtime-api 3 | sonar.projectName=Scraped TV Time API 4 | sonar.sources=src/ 5 | sonar.tests=test/ -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "session_key": "my super secret key", 3 | "http_headers": { 4 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36" 5 | } 6 | } -------------------------------------------------------------------------------- /src/etc/ErrorHandlers.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from flask import jsonify 4 | 5 | 6 | def ko_error(error) -> Any: 7 | return jsonify({'status': 'KO', 'reason': '{} - {}'.format(error.code, error.description)}), error.code 8 | -------------------------------------------------------------------------------- /src/etc/Config.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | 4 | with open('config.json', 'r') as fp_config: 5 | config_data = json.load(fp_config) 6 | HEADERS = config_data['http_headers'] 7 | SESSION_KEY = base64.b64decode(config_data['session_key']) 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-alpine 2 | COPY src/ /app/src/ 3 | COPY requirements.txt /app/ 4 | COPY Makefile /app/ 5 | COPY config.json /app/ 6 | 7 | WORKDIR /app 8 | 9 | RUN pip3 install -r requirements.txt 10 | CMD export FLASK_APP=/app/src/main.py && python3 -m flask run --host=0.0.0.0 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install test debug_remote remote_debug_dep 2 | 3 | install: 4 | python -m pip install --upgrade pip 5 | pip install -r requirements.txt 6 | 7 | remote_debug_dep: 8 | pip install debugpy 9 | 10 | debug_remote: install remote_debug_dep 11 | python -m debugpy --listen 0.0.0.0:5678 --wait-for-client -m flask run -h 0.0.0 -p 5000 12 | 13 | test: 14 | coverage run --source=src/ -m unittest discover -v 15 | 16 | docker_build: 17 | docker build -t scraped-tvtime . 18 | 19 | docker_run: docker_build 20 | docker run -p 8000:5000 scraped-tvtime -------------------------------------------------------------------------------- /test/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "series": [ 3 | { 4 | "id": 73244, 5 | "name": "The Office", 6 | "count_watched": 35, 7 | "watched": { 8 | "season": 3, 9 | "episode": 7 10 | } 11 | }, 12 | { 13 | "id": 121361, 14 | "name": "Game of Thrones", 15 | "count_watched": 67, 16 | "watched": { 17 | "season": 7, 18 | "episode": 7 19 | } 20 | }, 21 | { 22 | "id": 355567, 23 | "name": "The Boys", 24 | "count_watched": 8, 25 | "watched": { 26 | "season": 1, 27 | "episode": 8 28 | } 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | schedule: 9 | - cron: '0 0 */5 * *' 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | max-parallel: 1 16 | matrix: 17 | python-version: [3.6, 3.7, 3.8, 3.9] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: make install 27 | - name: Test with coverage 28 | run: make test 29 | - name: Make coverage report 30 | uses: codecov/codecov-action@v1.3.1 31 | -------------------------------------------------------------------------------- /test/BaseTestClass.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import unittest 4 | 5 | import requests 6 | 7 | from src.etc import Config 8 | from test import TestUtil 9 | 10 | 11 | class BaseTestClass(unittest.TestCase): 12 | @classmethod 13 | def setUpClass(cls) -> None: 14 | cls._cookies, cls._username, cls._password = TestUtil.create_user() 15 | 16 | # Load the shows in memory 17 | with open('test/config.json', 'r') as config_fp: 18 | cls._expected_data = json.load(config_fp) 19 | 20 | cls._cookies = TestUtil.add_shows(cls._cookies, cls._expected_data) 21 | 22 | @classmethod 23 | def tearDownClass(cls) -> None: 24 | logging.info("Deleting {}... ".format(cls._username)) 25 | res = requests.delete('https://www.tvtime.com/settings/delete_account', headers=Config.HEADERS, 26 | cookies=cls._cookies) 27 | res.raise_for_status() 28 | 29 | 30 | if __name__ == '__main__': 31 | unittest.main() 32 | -------------------------------------------------------------------------------- /test/test_shows.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from src.main import app 4 | from test.BaseTestClass import BaseTestClass 5 | 6 | 7 | class ShowsTestCase(BaseTestClass): 8 | def setUp(self) -> None: 9 | app.config['SECRET_KEY'] = 'test_key' 10 | self.client = app.test_client() 11 | 12 | def test_when_not_logged_in_shows_should_ko(self): 13 | # Test 14 | response = self.client.get('/shows') 15 | 16 | # Verify 17 | self.assertEqual(401, response.status_code) 18 | 19 | def test_when_fetching_shows_should_return_correct_data(self): 20 | # Given 21 | self.client.post('/login', data={'username': self._username, 'password': self._password}) 22 | 23 | # Test 24 | response = self.client.get('/shows') 25 | 26 | # Verify 27 | json_data = response.json 28 | self.assertEqual(json_data['count'], len(self._expected_data['series'])) 29 | output_ids = [series['id'] for series in json_data['series']] 30 | unmatched_ids = [] 31 | for series in self._expected_data['series']: 32 | expected_id = series['id'] 33 | if expected_id not in output_ids: 34 | unmatched_ids.append(expected_id) 35 | self.assertEqual(0, len(unmatched_ids), "The following IDs are not in the output: {}".format(unmatched_ids)) 36 | 37 | 38 | if __name__ == '__main__': 39 | unittest.main() 40 | -------------------------------------------------------------------------------- /src/endpoints_processors/Login.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from bs4 import BeautifulSoup 4 | from flask import session 5 | 6 | from src.etc import Utils 7 | 8 | 9 | def check_login_data(params: Dict) -> bool: 10 | if not Utils.are_form_data_keys_valid(params, ['username', 'password']): 11 | return False 12 | return Utils.are_form_data_values_valid(params) 13 | 14 | 15 | def do_login(username: str, password: str) -> bool: 16 | resp_login = Utils.get('https://www.tvtime.com/login', False) 17 | symfony_cookie = resp_login.cookies['symfony'] 18 | post_data = {'symfony': symfony_cookie, 'username': username, 'password': password} 19 | resp_signin = Utils.post('https://www.tvtime.com/signin', post_data, False) 20 | if len(resp_signin.history) == 0 or 'symfony' not in resp_signin.history[0].cookies or 'tvstRemember' not in \ 21 | resp_signin.history[0].cookies: 22 | return False 23 | user_id = __get_user_id(resp_signin.text) 24 | if len(user_id) > 0: 25 | session['username'] = {'symfony': resp_signin.history[0].cookies['symfony'], 26 | 'tvstRemember': resp_signin.history[0].cookies['tvstRemember'], 27 | 'user_id': user_id} 28 | return True 29 | return False 30 | 31 | 32 | def __get_user_id(html_page: str) -> str: 33 | parser = BeautifulSoup(html_page, 'html.parser') 34 | return parser.select_one('li.profile > a[href*="user/"]')['href'].split('/')[3] 35 | -------------------------------------------------------------------------------- /test/test_login.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | 4 | import flask 5 | 6 | from src.main import app 7 | from test import TestUtil 8 | from test.BaseTestClass import BaseTestClass 9 | 10 | 11 | class LoginTestCase(BaseTestClass): 12 | @classmethod 13 | def setUpClass(cls) -> None: 14 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 15 | cls._cookies, cls._username, cls._password = TestUtil.create_user() 16 | 17 | def setUp(self) -> None: 18 | app.config['SECRET_KEY'] = 'test_key' 19 | self.client = app.test_client() 20 | 21 | def test_when_wrong_keys_should_ko(self): 22 | # Given 23 | payload = {'wrong': 'data'} 24 | 25 | # Test 26 | result = self.client.post('/login', data=payload) 27 | 28 | # Verify 29 | self.assertEqual(400, result.status_code) 30 | 31 | def test_when_empty_user_or_pass_should_ko(self): 32 | # Given 33 | payload = {'username': ' ', 'password': 'whatever'} 34 | 35 | # Test 36 | result = self.client.post('/login', data=payload) 37 | 38 | # Verify 39 | self.assertEqual(400, result.status_code) 40 | 41 | def test_when_wrong_user_or_pass_should_ko(self): 42 | # Given 43 | payload = {'username': 'dummy', 'password': 'still_dummy'} 44 | 45 | # Test 46 | result = self.client.post('/login', data=payload) 47 | 48 | # Verify 49 | self.assertEqual(401, result.status_code) 50 | 51 | def test_when_correct_user_and_pass_should_ok(self): 52 | # Given 53 | payload = {'username': self._username, 'password': self._password} 54 | 55 | with self.client as c: 56 | with c.session_transaction() as s: 57 | s['username'] = {'dummy': '42'} 58 | # Test 59 | result = c.post('/login', data=payload) 60 | has_dummy_key = 'dummy' in flask.session['username'] 61 | has_userdata = 'user_id' in flask.session['username'] and 'symfony' in flask.session[ 62 | 'username'] and 'tvstRemember' in flask.session['username'] 63 | 64 | # Verify 65 | self.assertEqual(result.json['status'], 'OK') 66 | self.assertFalse(has_dummy_key) 67 | self.assertTrue(has_userdata) 68 | 69 | 70 | if __name__ == '__main__': 71 | unittest.main() 72 | -------------------------------------------------------------------------------- /test/TestUtil.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from typing import Tuple, Dict, Any 4 | 5 | import requests 6 | 7 | from src.etc import Config 8 | 9 | 10 | def create_user() -> Tuple[Dict, str, str]: 11 | current_time = int(time.time()) 12 | username = 'user{}'.format(current_time) 13 | password = username 14 | email_address = '{username}@asd.asd'.format(username=username) 15 | logging.info('Creating {}... '.format(username)) 16 | resp = requests.post('https://www.tvtime.com/signup', headers=Config.HEADERS, 17 | data={'username': username, 'password': password, 'email': email_address}) 18 | resp.raise_for_status() 19 | history_cookies = {'symfony': resp.history[0].cookies.get('symfony', ''), 20 | 'tvstRemember': resp.history[0].cookies.get('tvstRemember', '')} 21 | cookies = {'symfony': resp.cookies.get('symfony', ''), 'tvstRemember': resp.cookies.get('tvstRemember', '')} 22 | if all(cookies.values()): 23 | return cookies, username, password 24 | elif all(history_cookies.values()): 25 | return history_cookies, username, password 26 | else: 27 | error = "Failed to create user\n\tStatus code={code}\nNo cookies found!".format(code=resp.status_code) 28 | logging.error(error) 29 | raise ConnectionError(error) 30 | 31 | 32 | def add_shows(cookies: Dict[str, Any], shows_data: Dict[str, Any]) -> Dict[str, Any]: 33 | # Add shows 34 | for series in shows_data['series']: 35 | logging.info('Adding {}... '.format(series['name'])) 36 | cookies = _put_and_return_cookies('https://www.tvtime.com/followed_shows', {'show_id': series['id']}, cookies) 37 | 38 | watched_until_payload = {'season': series['watched']['season'], 'episode': series['watched']['episode'], 39 | 'show_id': series['id']} 40 | cookies = _put_and_return_cookies('https://www.tvtime.com/show_watch_until', watched_until_payload, cookies) 41 | return cookies 42 | 43 | 44 | def _put_and_return_cookies(url: str, payload: Dict[str, Any], cookies: Dict[str, Any]) -> Dict[str, Any]: 45 | response = requests.put(url, headers=Config.HEADERS, data=payload, cookies=cookies) 46 | response.raise_for_status() 47 | return {'symfony': response.cookies.get('symfony', cookies['symfony']), 48 | 'tvstRemember': response.cookies.get('tvstRemember', cookies['tvstRemember'])} 49 | -------------------------------------------------------------------------------- /src/etc/Utils.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Any 2 | 3 | import requests 4 | from flask import session, jsonify 5 | 6 | from src.etc import Config 7 | 8 | 9 | def are_form_data_keys_valid(params: Dict[str, any], expected_keys: List[str]) -> bool: 10 | for key in expected_keys: 11 | if key not in params: 12 | return False 13 | return True 14 | 15 | 16 | def are_form_data_values_valid(params: Dict[str, str]) -> bool: 17 | for value in params.values(): 18 | if len(value.strip()) == 0: 19 | return False 20 | return True 21 | 22 | 23 | def get_tvtime_cookies(): 24 | return {'symfony': session['username']['symfony'], 'tvstRemember': session['username']['tvstRemember']} 25 | 26 | 27 | def update_tvtime_cookies(cookies: Any) -> None: 28 | old_session_cookie = session['username']['symfony'] 29 | old_remember_cookie = session['username']['tvstRemember'] 30 | session['username']['symfony'] = cookies.get('symfony', old_session_cookie) 31 | session['username']['tvstRemember'] = cookies.get('tvstRemember', old_remember_cookie) 32 | 33 | 34 | def ok_response() -> Any: 35 | return jsonify({'status': 'OK'}) 36 | 37 | 38 | def get(url: str, update_session=True, **kwargs: Any) -> requests.Response: 39 | resp = requests.get(url, headers=Config.HEADERS, cookies=kwargs.get('cookies', {})) 40 | resp.raise_for_status() 41 | if update_session: 42 | update_tvtime_cookies(resp.cookies) 43 | return resp 44 | 45 | 46 | def post(url: str, data: Dict[str, Any], update_session=True, **kwargs: Any) -> requests.Response: 47 | resp = requests.post(url, data=data, headers=Config.HEADERS, cookies=kwargs.get('cookies', {})) 48 | resp.raise_for_status() 49 | if update_session: 50 | update_tvtime_cookies(resp.cookies) 51 | return resp 52 | 53 | 54 | def put(url: str, data: Dict[str, Any], update_session=True, **kwargs: Any) -> requests.Response: 55 | resp = requests.put(url, data=data, headers=Config.HEADERS, cookies=kwargs.get('cookies', {})) 56 | resp.raise_for_status() 57 | if update_session: 58 | update_tvtime_cookies(resp.cookies) 59 | return resp 60 | 61 | 62 | def delete(url: str, data: Dict[str, Any], update_session=True, **kwargs: Any) -> requests.Response: 63 | resp = requests.delete(url, data=data, headers=Config.HEADERS, cookies=kwargs.get('cookies', {})) 64 | resp.raise_for_status() 65 | if update_session: 66 | update_tvtime_cookies(resp.cookies) 67 | return resp 68 | -------------------------------------------------------------------------------- /src/endpoints_processors/Shows.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Any 2 | 3 | from bs4 import BeautifulSoup 4 | from flask import session 5 | 6 | from src.etc import Utils 7 | 8 | 9 | def get_shows() -> Dict[str, any]: 10 | resp = Utils.get('https://www.tvtime.com/en/user/{}/profile'.format(session['username']['user_id']), 11 | cookies=Utils.get_tvtime_cookies()) 12 | series = parse_series_list(resp.text) 13 | return {'series': series, 'count': len(series)} 14 | 15 | 16 | def parse_series_list(shows_page: str) -> List[any]: 17 | parser = BeautifulSoup(shows_page, 'html.parser') 18 | series = list() 19 | for element in parser.select('div#all-shows ul.shows-list.posters-list div.show'): 20 | show_name = element.select_one('div.poster-details > h2 > a').text.strip() 21 | if show_name == '': 22 | continue 23 | show_id = element.select_one('a.show-link')['href'].split('/')[3] 24 | progress = element.select_one('a.show-link div.progress-bar')['style'].split(':')[1].strip() 25 | time = element.select_one('div.poster-details > h3').text.strip() 26 | series.append({'id': int(show_id), 'progress': progress, 'name': show_name, 'time': time}) 27 | return series 28 | 29 | 30 | def get_show(show_id: int) -> Dict[str, Any]: 31 | resp = Utils.get('https://www.tvtime.com/en/show/{}'.format(show_id), cookies=Utils.get_tvtime_cookies()) 32 | seasons = parse_season_list(resp.text) 33 | return {"count": len(seasons), "seasons": seasons} 34 | 35 | 36 | def parse_season_list(show_page: str) -> List[Any]: 37 | parser = BeautifulSoup(show_page, 'html.parser') 38 | seasons = list() 39 | for season_element in parser.select("div#show-seasons > div.seasons > div.season-content"): 40 | season_name = season_element.select_one("span[itemprop='name']").text.strip() 41 | num_episodes = season_element.select_one("span[itemprop='numberOfEpisodes']").text.strip() 42 | episodes = list() 43 | for episode_element in season_element.select("ul.episode-list > li.episode-wrapper > div.infos > div.row"): 44 | episode_id = episode_element.select_one('a')['href'].split('/')[5] 45 | episode_number = episode_element.select_one('span.episode-nb-label').text.strip() 46 | episode_name = episode_element.select_one('span.episode-name').text.strip() 47 | episode_air_date = episode_element.select_one('span.episode-air-date').text.strip() 48 | watched = 'active' in episode_element.parent.parent.select_one('div.actions > div.row > a')['class'] 49 | episodes.append( 50 | {"id": episode_id, "number": episode_number, "name": episode_name, "air_date": episode_air_date, 51 | "watched": watched}) 52 | seasons.append({"name": season_name, "number_of_episodes": num_episodes, "episodes": episodes}) 53 | return seasons 54 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify, request, session 2 | from werkzeug.exceptions import abort 3 | 4 | from src.endpoints_processors import Login, Shows 5 | from src.etc import Config, Utils, ErrorHandlers 6 | 7 | app = Flask(__name__) 8 | app.secret_key = Config.SESSION_KEY 9 | 10 | app.register_error_handler(400, ErrorHandlers.ko_error) 11 | app.register_error_handler(401, ErrorHandlers.ko_error) 12 | app.register_error_handler(404, ErrorHandlers.ko_error) 13 | app.register_error_handler(502, ErrorHandlers.ko_error) 14 | 15 | 16 | @app.route('/') 17 | def index(): 18 | return abort(404, 'Page not found') 19 | 20 | 21 | @app.route('/login', methods=['POST']) 22 | def login(): 23 | session.pop('username', None) 24 | if not Login.check_login_data(request.form): 25 | return abort(400, 'Invalid POST data') 26 | if Login.do_login(request.form['username'], request.form['password']): 27 | return Utils.ok_response() 28 | return abort(401, 'Not logged in') 29 | 30 | 31 | @app.route('/shows') 32 | def shows(): 33 | if 'username' not in session: 34 | return abort(401, 'Not logged in') 35 | data = Shows.get_shows() 36 | return jsonify(data) 37 | 38 | 39 | @app.route('/show/') 40 | def show(show_id: int): 41 | if 'username' not in session: 42 | return abort(401, 'Not logged in') 43 | return jsonify(Shows.get_show(show_id)) 44 | 45 | 46 | @app.route('/show//follow', methods=['PUT', 'DELETE']) 47 | def follow_show(show_id: int): 48 | if 'username' not in session: 49 | return abort(401, 'Not logged in') 50 | url_endpoint = 'https://www.tvtime.com/followed_shows' 51 | response = {} 52 | show_payload = {'show_id': show_id} 53 | 54 | if request.method == 'PUT': 55 | response = Utils.put(url_endpoint, show_payload, cookies=Utils.get_tvtime_cookies()).json() 56 | if 'result' not in response or response['result'] != 'OK': 57 | return abort(502, '{} request failed!'.format(request.method)) 58 | elif request.method == 'DELETE': 59 | response = Utils.delete(url_endpoint, show_payload, cookies=Utils.get_tvtime_cookies()) 60 | if not response.ok: 61 | return abort(502, '{} request failed!'.format(request.method)) 62 | 63 | return Utils.ok_response() 64 | 65 | 66 | @app.route('/episode//watched', methods=['PUT', 'DELETE']) 67 | def mark_watched(episode_id: int): 68 | if 'username' not in session: 69 | return abort(401, 'Not logged in') 70 | url_endpoint = 'https://www.tvtime.com/watched_episodes' 71 | response = {} 72 | episode_payload = {'episode_id': episode_id} 73 | 74 | if request.method == 'PUT': 75 | response = Utils.put(url_endpoint, episode_payload, 76 | cookies=Utils.get_tvtime_cookies()).json() 77 | if 'result' not in response or response['result'] != 'OK': 78 | return abort(502, '{} request failed!'.format(request.method)) 79 | elif request.method == 'DELETE': 80 | response = Utils.delete(url_endpoint, episode_payload, 81 | cookies=Utils.get_tvtime_cookies()) 82 | if not response.ok: 83 | return abort(502, '{} request failed!'.format(request.method)) 84 | 85 | return Utils.ok_response() 86 | -------------------------------------------------------------------------------- /test/test_episode.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from src.main import app 4 | from test.BaseTestClass import BaseTestClass 5 | 6 | 7 | class EpisodeTestCase(BaseTestClass): 8 | def setUp(self) -> None: 9 | app.config['SECRET_KEY'] = 'test_key' 10 | self.client = app.test_client() 11 | 12 | def test_when_not_logged_in_episode_watched_should_ko(self): 13 | # Test 14 | response = self.client.put('/episode/42/watched') 15 | 16 | # Verify 17 | self.assertEqual(401, response.status_code) 18 | 19 | def test_when_episode_watched_should_ok(self): 20 | # Given 21 | login_resp = self.client.post('/login', data={'username': self._username, 'password': self._password}) 22 | self.assertEqual('OK', login_resp.json['status']) 23 | selected_series = self._expected_data['series'][0] # The Office 24 | retrieved_show_data = self.client.get('/show/{}'.format(selected_series['id'])).json 25 | expected_episode_id = self.get_expected_episode_id(retrieved_show_data, False) 26 | 27 | # Test 28 | response = self.client.put('/episode/{}/watched'.format(expected_episode_id)) 29 | 30 | # Verify 31 | self.assertEqual('OK', response.json['status']) 32 | retrieved_show_data = self.client.get('/show/{}'.format(selected_series['id'])).json 33 | expected_watched_episode = self.get_expected_episode(expected_episode_id, retrieved_show_data) 34 | self.assertTrue(expected_watched_episode['watched'], 35 | "Episode '{name}' with id {ep_id} and number {num} has not been marked as watched".format( 36 | name=expected_watched_episode['name'], ep_id=expected_watched_episode['id'], 37 | num=expected_watched_episode['number'])) 38 | 39 | def test_when_episode_unwatched_should_ok(self): 40 | # Given 41 | login_resp = self.client.post('/login', data={'username': self._username, 'password': self._password}) 42 | self.assertEqual('OK', login_resp.json['status']) 43 | selected_series = self._expected_data['series'][0] # The Office 44 | retrieved_show_data = self.client.get('/show/{}'.format(selected_series['id'])).json 45 | expected_episode_id = self.get_expected_episode_id(retrieved_show_data, True) 46 | 47 | # Test 48 | response = self.client.delete('/episode/{}/watched'.format(expected_episode_id)) 49 | 50 | # Verify 51 | self.assertEqual('OK', response.json['status']) 52 | retrieved_show_data = self.client.get('/show/{}'.format(selected_series['id'])).json 53 | expected_unwatched_episode = self.get_expected_episode(expected_episode_id, retrieved_show_data) 54 | self.assertFalse(expected_unwatched_episode['watched'], 55 | "Episode '{name}' with id {ep_id} and number {num} is still marked as watched".format( 56 | name=expected_unwatched_episode['name'], ep_id=expected_unwatched_episode['id'], 57 | num=expected_unwatched_episode['number'])) 58 | 59 | def get_expected_episode_id(self, retrieved_show_data, watched): 60 | for season in retrieved_show_data['seasons']: 61 | for episode in season['episodes']: 62 | if episode['watched'] == watched: 63 | return episode['id'] 64 | 65 | def get_expected_episode(self, expected_episode_id, retrieved_show_data): 66 | for season in retrieved_show_data['seasons']: 67 | for episode in season['episodes']: 68 | if episode['id'] == expected_episode_id: 69 | return episode 70 | 71 | 72 | if __name__ == '__main__': 73 | unittest.main() 74 | -------------------------------------------------------------------------------- /test/test_single_show.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import unittest 4 | 5 | from src.main import app 6 | from test.BaseTestClass import BaseTestClass 7 | 8 | 9 | class SingleShowTestCase(BaseTestClass): 10 | def setUp(self) -> None: 11 | app.config['SECRET_KEY'] = 'test_key' 12 | self.client = app.test_client() 13 | 14 | def test_when_not_logged_in_show_should_ko(self): 15 | # Test 16 | response = self.client.get('/show/42') 17 | 18 | # Verify 19 | self.assertEqual(401, response.status_code) 20 | 21 | def test_when_fetching_single_show_should_return_episodes(self): 22 | # Given 23 | login_resp = self.client.post('/login', data={'username': self._username, 'password': self._password}) 24 | self.assertEqual('OK', login_resp.json['status']) 25 | selected_show_index = random.randrange(0, len(self._expected_data['series'])) 26 | selected_series = self._expected_data['series'][selected_show_index] 27 | logging.info("Testing with series {}".format(selected_series['name'])) 28 | selected_series_id = selected_series['id'] 29 | 30 | # Test 31 | response = self.client.get('/show/{}'.format(selected_series_id)) 32 | json_data = response.json 33 | 34 | # Verify 35 | watched_episodes = sum( 36 | [episode['watched'] for season in json_data['seasons'] for episode in season['episodes']]) 37 | self.assertEqual(selected_series['count_watched'], watched_episodes) 38 | 39 | def test_when_not_logged_in_and_following_show_should_ko(self): 40 | # Given 41 | to_follow_data = {'id': 295829, 'name': 'The Man in the High Castle'} 42 | 43 | # Test 44 | response = self.client.put('/show/{}/follow'.format(to_follow_data['id'])) 45 | 46 | # Verify 47 | self.assertEqual(401, response.status_code) 48 | 49 | def test_when_following_show_should_ok(self): 50 | # Given 51 | login_resp = self.client.post('/login', data={'username': self._username, 'password': self._password}) 52 | self.assertEqual('OK', login_resp.json['status']) 53 | expected_shows = self.client.get('/shows').json 54 | expected_count = expected_shows['count'] + 1 55 | to_follow_data = {'id': 295829, 'name': 'The Man in the High Castle'} 56 | 57 | # Test 58 | response = self.client.put('/show/{}/follow'.format(to_follow_data['id'])).json 59 | 60 | # Verify 61 | self.assertEqual('OK', response['status']) 62 | followed_shows = self.client.get('/shows') 63 | self.assertEqual(expected_count, followed_shows.json['count']) 64 | 65 | fetched_show_ids = [series['id'] for series in followed_shows.json['series']] 66 | self.assertTrue(to_follow_data['id'] in fetched_show_ids) 67 | 68 | def test_when_unfollowing_show_should_ok(self): 69 | # Given 70 | login_resp = self.client.post('/login', data={'username': self._username, 'password': self._password}) 71 | self.assertEqual('OK', login_resp.json['status']) 72 | expected_shows = self.client.get('/shows').json 73 | expected_count = expected_shows['count'] - 1 74 | to_unfollow_show_id = expected_shows['series'][random.randrange(0, expected_shows['count'])]['id'] 75 | 76 | # Test 77 | response = self.client.delete('/show/{}/follow'.format(to_unfollow_show_id)).json 78 | 79 | # Verify 80 | self.assertEqual('OK', response['status']) 81 | followed_shows = self.client.get('/shows') 82 | self.assertEqual(expected_count, followed_shows.json['count']) 83 | 84 | fetched_show_ids = [series['id'] for series in followed_shows.json['series']] 85 | self.assertFalse(to_unfollow_show_id in fetched_show_ids) 86 | 87 | 88 | if __name__ == '__main__': 89 | unittest.main() 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This project is archived 2 | 3 | Unfortunately TVTime has completely reworked their internal API. So the project is broken. 4 | 5 | Also, in hindsight, it doesn't make sense anymore to set the project like this: I believe a future work should provide just an API in the form a library. 6 | 7 | It's been a fun project though. 8 | 9 | --- 10 | 11 | # TVTime Flask API [![codecov](https://codecov.io/gh/Kwbmm/scraped-tvtime-api/branch/master/graph/badge.svg?token=CWB4FE67O1)](https://codecov.io/gh/Kwbmm/scraped-tvtime-api) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=tvtime-api&metric=alert_status)](https://sonarcloud.io/dashboard?id=tvtime-api) 12 | 13 | This is a proof of concept of an API using TVTime service and not exploiting its own API system. This repository aims to provide at least the possibility of retrieving information about your own TV series that you are tracking on [TVTime](https://tvtime.com). 14 | 15 | ## The story (or reasons why this exists) 16 | The existing [TVTime API](https://api.tvtime.com/doc) has 2 main drawbacks in my opinion: 17 | 18 | 1. Requires the developer to send an email to TvTime for "approval". This greatly limits the possibility of creating new projects. If the project is not deemed worthy of the approval, you won't get any API key. 19 | 2. The API cannot be used for personal usage. 20 | 21 | Back in the days when I was using Plex, I had planned to create a plugin to automatically mark as 'watched' the episodes I was watching through Plex. A Plex plugin already existed, but it was unmantained and I had issues with it. I did ask TVtime for an API key. No response received. I put the project aside and moved on with my life. 22 | 23 | Today, I am looking into learning Flask and so I thought about creating a free version of the API. Given what I have said, the API is limited to returning information on current followed shows and can be used to mark episodes as watched/unwatched. I don't expect to add more. 24 | 25 | Regarding the second drawback: I don't really see why I am not allowed to access the data of the shows that I am watching and do whatever I like with it (privately or not), without asking for permission to a third-party (TvTime). 26 | 27 | Finally, this "API" is nothing more than an automated way to log into your account. 28 | 29 | ## The future 30 | My plan is to give a base from which people can start developing. It would be great seeing plugins for Plex, Emby and Jellyfin coming up. 31 | 32 | ## The idea 33 | The way the API works is by providing some rest endpoints with Flask. When a request for the endpoint arrives, python `requests` takes care of forwarding the request to TVTime. TVTime cookies are handled internally by the Flask API. 34 | ``` 35 | User──────>JSON request──────>Flask API──────>request───>TVTime.com──┐ 36 | │ 37 | User<─────JSON reply<─────Beautiful Soup scraping<──────response<────┘ 38 | ``` 39 | 40 | The response returned by TVTime generally holds 2 cookies, which are stored internally in Flask: Flask generates a session cookie that the user will use to perform the requests. 41 | 42 | The response returns the HTML page as well, which will be scraped with BeautifulSoup for useful information. The data will be rearragend and put inside a JSON file, that will be returned to the user. 43 | 44 | ## Requirements 45 | Not many, except for a TVTime account. But if you are here, you probably already have one. 46 | 47 | `requirements.txt` takes care of installing, through `pip` what is needed to run the API. 48 | 49 | 50 | ## Development 51 | 52 | This codebase is written in Python 3 and requires `pip`. 53 | 54 | `pip` and requirements are installed by running `make install` 55 | 56 | ## Testing and debugging 57 | Tests can be run with `make test`. This will run the tests through `coverage` library to generate a coverage report. 58 | 59 | If you wish, for debugging purposes, to run a single test, you can do so by running `python -m unittest discover -k test_name` (e.g. `python -m unittest discover -k test_when_unfollowing_show_should_ok`) 60 | 61 | ### Remote debugging 62 | The project can be run inside a docker container, thus remote debugging is a possibility. Remote debugging allows the user to send actual requests through third-party clients (like Postman or from Chrome/Firefox built-in network tab) to this API. 63 | 64 | **1) Prereqs:** 65 | - A python container on which you've already run the setup (i.e. `make install`) 66 | - VSCode with the python plugin installed 67 | 68 | **2) Inside the container** 69 | 70 | - Run `FLASK_APP=` 71 | - Run `make debug_remote` 72 | 73 | A flask webserver should be started and waiting for new requests. 74 | 75 | **3) In your VSCode** 76 | 77 | You should have setup a `launch.json` inside `.vscode` folder. The file can be easily setup through VSCode remote debugging configuration wizard. Main points to highlights are: 78 | 79 | - The container runs the debug server on port 5678, so the port should be opened on container and the VSCode configuration should point to that port 80 | - The flask API runs on port 5000 inside the container. That port must be opened as well. 81 | - The VSCode config will contain a `pathMappings` object that needs to be setup properly. Specifically, `remoteRoot` should point to the folder containing this project inside the container, while `localRoot` should point to the folder on your local env (laptop). 82 | 83 | Here's an example of my config: 84 | 85 | ```json 86 | { 87 | "version": "0.2.0", 88 | "configurations": [ 89 | { 90 | "name": "Python: Remote Attach", 91 | "type": "python", 92 | "request": "attach", 93 | "connect": { 94 | "host": "127.0.0.1", 95 | "port": 5678 96 | }, 97 | "pathMappings": [ 98 | { 99 | "localRoot": "${workspaceFolder}", 100 | "remoteRoot": "/src/scraped-tvtime-api/" 101 | } 102 | ] 103 | } 104 | ] 105 | } 106 | ``` 107 | If the `launch.json` is correctly setup, you should be able to attach to the remote debugger. If you place breakpoints in the code and try to hit the endpoints, VSCode should stop execution at the breakpoints. 108 | 109 | ## Usage 110 | 111 | **Before using this API, please make sure that the backend on which you are running this Flask API can be accessed over secure http requests. Since you are sending login information containing your ID and password, you should NEVER send them on unsecure connections.** 112 | 113 | ### APIs 114 | *The following API is a work in progress* 115 | 116 | |**URL**|**Method**|**Data**|**Response**| 117 | |---|---|---|---| 118 | |/login |POST |username + password (in request body) |session cookie | 119 | |/shows |GET | session cookie (in request header) | List of series you are tracking | 120 | |/show/\ |GET |session cookie (in request header) |List of seasons + episodes for show `id` | 121 | |/show/\/follow |PUT |session cookie (in request header) |Start following the show corresponding to `id` | 122 | |/show/\/follow |DELETE |session cookie (in request header) |Stop following the show corresponding to `id` | 123 | |/episode/\/watched |PUT |session cookie (in request header) |Mark episode `id` as watched | 124 | |/episode/\/watched |DELETE |session cookie (in request header) |Mark episode `id` as not watched | 125 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | --------------------------------------------------------------------------------