├── .coveragerc ├── .flake8 ├── .github └── workflows │ └── python.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── examples └── sample.py ├── listennotes ├── __init__ ├── errors.py ├── http_utils.py ├── podcast_api.py └── version.py ├── pyproject.toml ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── test_client.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | listennotes 4 | */site-packages 5 | 6 | [run] 7 | branch = true 8 | parallel = true 9 | source = listennotes 10 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # [flake8] 2 | # exclude = 3 | # setup.py 4 | 5 | # E501 is the "Line too long" error. We disable it because we use Black for 6 | # code formatting. Black makes a best effort to keep lines under the max 7 | # length, but can go over in some cases. 8 | # W503 goes against PEP8 rules. It's disabled by default, but must be disabled 9 | # explicitly when using `ignore`. 10 | # ignore = E501, W503 -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | python-version: ['3.10', '3.11', '3.12', '3.13'] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - run: make 25 | - run: make test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .idea/ 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Listen Notes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VENV_NAME?=venv 2 | PIP?=pip3 3 | PYTHON?=python3 4 | 5 | venv: $(VENV_NAME)/bin/activate 6 | 7 | $(VENV_NAME)/bin/activate: setup.py 8 | $(PIP) install --upgrade pip virtualenv 9 | @test -d $(VENV_NAME) || $(PYTHON) -m virtualenv --clear $(VENV_NAME) 10 | ${VENV_NAME}/bin/python -m pip install -U pip tox 11 | ${VENV_NAME}/bin/python -m pip install -e . 12 | @touch $(VENV_NAME)/bin/activate 13 | 14 | run: venv 15 | @${VENV_NAME}/bin/python examples/sample.py 16 | 17 | test: venv 18 | @${VENV_NAME}/bin/tox -p auto $(TOX_ARGS) 19 | 20 | test-nomock: venv 21 | @${VENV_NAME}/bin/tox -p auto -- --nomock $(TOX_ARGS) 22 | 23 | test-travis: venv 24 | ${VENV_NAME}/bin/python -m pip install -U tox-travis 25 | @${VENV_NAME}/bin/tox -p auto $(TOX_ARGS) 26 | 27 | fmt: venv 28 | @${VENV_NAME}/bin/tox -e fmt 29 | 30 | fmtcheck: venv 31 | @${VENV_NAME}/bin/tox -e fmt -- --check --verbose 32 | 33 | lint: venv 34 | @${VENV_NAME}/bin/tox -e lint 35 | 36 | publish-test: test 37 | ${VENV_NAME}/bin/python -m pip install --upgrade twine 38 | ${VENV_NAME}/bin/python -m twine upload --repository testpypi dist/* 39 | 40 | publish: test 41 | ${VENV_NAME}/bin/python -m pip install --upgrade twine 42 | ${VENV_NAME}/bin/python -m twine upload --repository pypi dist/* 43 | 44 | clean: 45 | @rm -rf $(VENV_NAME) .coverage .coverage.* build/ dist/ htmlcov/ *.egg-info .tox 46 | 47 | .PHONY: venv test test-nomock test-travis coveralls fmt fmtcheck lint clean 48 | -------------------------------------------------------------------------------- /examples/sample.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from listennotes import podcast_api, errors 5 | 6 | # Get your api key here: https://www.listennotes.com/api/dashboard/ 7 | api_key = os.environ.get("LISTEN_API_KEY", None) 8 | 9 | client = podcast_api.Client(api_key=api_key) 10 | 11 | # 12 | # Boilerplate to make an api call 13 | # 14 | try: 15 | response = client.typeahead(q="startup", show_podcasts=1) 16 | print(json.dumps(response.json(), indent=2)) 17 | except errors.APIConnectionError: 18 | print("Failed ot connect to Listen API servers") 19 | except errors.AuthenticationError: 20 | print("Wrong api key, or your account has been suspended!") 21 | except errors.InvalidRequestError: 22 | print("Wrong parameters!") 23 | except errors.NotFoundError: 24 | print("Endpoint not exist or the podcast / episode not exist!") 25 | except errors.RateLimitError: 26 | print("Reached your quota limit, or rate limit.") 27 | except errors.ListenApiError: 28 | print("Something wrong on Listen Notes servers") 29 | except Exception: 30 | print("Other errors that may not be related to Listen API") 31 | else: 32 | headers = response.headers 33 | print("\n=== Some account info ===") 34 | print( 35 | "Free Quota this month: %s requests" 36 | % headers.get("X-ListenAPI-FreeQuota") 37 | ) 38 | print("Usage this month: %s requests" % headers.get("X-ListenAPI-Usage")) 39 | print("Next billing date: %s" % headers.get("X-Listenapi-NextBillingDate")) 40 | 41 | # response = client.search(q='startup') 42 | # print(response.json()) 43 | 44 | # response = client.search_episode_titles(q='Jerusalem Demsas on The Dispossessed') 45 | # print(response.json()) 46 | 47 | # response = client.spellcheck(q='evergrand stok') 48 | # print(response.json()) 49 | 50 | # response = client.fetch_related_searches(q='evergrande') 51 | # print(response.json()) 52 | 53 | # response = client.fetch_trending_searches() 54 | # print(response.json()) 55 | 56 | # response = client.fetch_best_podcasts() 57 | # print(response.json()) 58 | 59 | # response = client.fetch_best_podcasts() 60 | # print(response.json()) 61 | 62 | # response = client.fetch_podcast_by_id(id='4d3fe717742d4963a85562e9f84d8c79') 63 | # print(response.json()) 64 | 65 | # response = client.fetch_episode_by_id(id='6b6d65930c5a4f71b254465871fed370') 66 | # print(response.json()) 67 | 68 | # response = client.batch_fetch_episodes(ids='c577d55b2b2b483c969fae3ceb58e362,0f34a9099579490993eec9e8c8cebb82') 69 | # print(response.json()) 70 | 71 | # response = client.batch_fetch_podcasts(ids='3302bc71139541baa46ecb27dbf6071a,68faf62be97149c280ebcc25178aa731,' 72 | # '37589a3e121e40debe4cef3d9638932a,9cf19c590ff0484d97b18b329fed0c6a') 73 | # print(response.json()) 74 | 75 | # response = client.fetch_curated_podcasts_list_by_id(id='SDFKduyJ47r') 76 | # print(response.json()) 77 | 78 | # response = client.fetch_curated_podcasts_lists(page=2) 79 | # print(response.json()) 80 | 81 | # response = client.fetch_curated_podcasts_lists(page=2) 82 | # print(response.json()) 83 | 84 | # response = client.fetch_podcast_genres(top_level_only=0) 85 | # print(response.json()) 86 | 87 | # response = client.fetch_podcast_regions() 88 | # print(response.json()) 89 | 90 | # response = client.fetch_podcast_languages() 91 | # print(response.json()) 92 | 93 | # response = client.just_listen() 94 | # print(response.json()) 95 | 96 | # response = client.fetch_recommendations_for_podcast(id='25212ac3c53240a880dd5032e547047b', safe_mode=1) 97 | # print(response.json()) 98 | 99 | # response = client.fetch_recommendations_for_episode(id='914a9deafa5340eeaa2859c77f275799', safe_mode=1) 100 | # print(response.json()) 101 | 102 | # response = client.fetch_playlist_by_id(id='m1pe7z60bsw', type='podcast_list') 103 | # print(response.json()) 104 | 105 | # response = client.fetch_my_playlists() 106 | # print(response.json()) 107 | 108 | # response = client.submit_podcast(rss='https://feeds.megaphone.fm/committed') 109 | # print(response.json()) 110 | 111 | # response = client.delete_podcast( 112 | # id='4d3fe717742d4963a85562e9f84d8c79', reason='the podcaster wants to delete it') 113 | # print(response.json()) 114 | 115 | # response = client.fetch_audience_for_podcast(id='25212ac3c53240a880dd5032e547047b') 116 | # print(response.json()) 117 | -------------------------------------------------------------------------------- /listennotes/__init__: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ListenNotes/podcast-api-python/20537212af78f1de5612ec7c74ababa07419823b/listennotes/__init__ -------------------------------------------------------------------------------- /listennotes/errors.py: -------------------------------------------------------------------------------- 1 | class ListenApiError(Exception): 2 | """ 3 | Display a very generic error to the user 4 | """ 5 | 6 | def __init__(self, message=None, response=None): 7 | super(ListenApiError, self).__init__(message) 8 | self._message = message 9 | self.response = response 10 | 11 | def __str__(self): 12 | return self._message 13 | 14 | 15 | class NotFoundError(ListenApiError): 16 | """ 17 | Endpoint not exist or the podcast / episode not exist 18 | """ 19 | 20 | pass 21 | 22 | 23 | class InvalidRequestError(ListenApiError): 24 | """ 25 | Invalid parameters were supplied to Listen API 26 | """ 27 | 28 | pass 29 | 30 | 31 | class AuthenticationError(ListenApiError): 32 | """ 33 | Authentication with Listen API failed 34 | """ 35 | 36 | pass 37 | 38 | 39 | class RateLimitError(ListenApiError): 40 | """ 41 | For FREE plan, exceeding the quota limit; or for all plans, 42 | sending too many requests too fast and exceeding the rate limit 43 | - https://www.listennotes.com/api/faq/#faq17 44 | """ 45 | 46 | pass 47 | 48 | 49 | class APIConnectionError(ListenApiError): 50 | """ 51 | Network communication with Listen API failed 52 | """ 53 | 54 | pass 55 | -------------------------------------------------------------------------------- /listennotes/http_utils.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from requests import exceptions 3 | 4 | from listennotes import errors 5 | 6 | 7 | class Request: 8 | """Making HTTP requests. 9 | 10 | Apply the best practices of making http requests: 11 | 1. timeout 12 | 2. retry 13 | 3. max redirects 14 | 4. exposes python-request exceptions, callers are responsible 15 | to handle them. 16 | """ 17 | 18 | MAX_RETRIES = 3 19 | MAX_REDIRECTS = 15 20 | TIMEOUT = 30 # seconds 21 | 22 | def __init__( 23 | self, 24 | max_redirects=MAX_REDIRECTS, 25 | max_retries=MAX_RETRIES, 26 | adapter=None, 27 | raise_exception=True, 28 | **kwargs 29 | ): 30 | """Set up a requests.Session object. 31 | 32 | Args: 33 | max_redirects: max redirects. 34 | max_retries: max retries. 35 | adapter: a custom requests.adapters.HTTPAdapter object. 36 | If this argument is specified, max_retries argument is ignored. 37 | kwargs: keyword args to set session attribute, e.g., auth. 38 | """ 39 | self.session = requests.Session() 40 | self.raise_exception = raise_exception 41 | if not adapter: 42 | the_adapter = requests.adapters.HTTPAdapter( 43 | max_retries=max_retries 44 | ) 45 | else: 46 | the_adapter = adapter 47 | 48 | for key, value in kwargs.items(): 49 | if hasattr(self.session, key): 50 | setattr(self.session, key, value) 51 | 52 | self.session.max_redirects = max_redirects 53 | self.session.mount("http://", the_adapter) 54 | self.session.mount("https://", the_adapter) 55 | 56 | def request(self, method, url, timeout=TIMEOUT, **kwargs): 57 | """Make a http(s) request. 58 | 59 | Args: 60 | method: http method name, should be one of DELETE', 'GET', 'HEAD', 61 | 'OPTIONS', 'PATCH', 'POST', 'PUT', and 'TRACE'. 62 | url: the url to request. 63 | timeout: request timeout. 64 | kwargs: keyword arguments. 65 | 66 | Returns: 67 | a response object. 68 | 69 | Raises: 70 | requests.exceptions.RequestException if there was an ambiguous 71 | exception that occurred while handling your request. This is 72 | the base class for all the following exceptions. 73 | 74 | requests.exceptions.ConnectionError if a Connection error occurred, 75 | e.g., DNS failure, refused connection, etc. 76 | 77 | requests.exceptions.HTTPError if an HTTP error occurred, i.e., 78 | status code is 4xx or 5xx. 79 | 80 | requests.exceptions.URLRequired if a valid URL is required to make, 81 | a request. 82 | 83 | requests.exceptions.TooManyRedirects if too many redirects. 84 | """ 85 | the_headers = {} 86 | if "headers" in kwargs: 87 | the_headers.update(kwargs["headers"]) 88 | del kwargs["headers"] 89 | 90 | response = self.session.request( 91 | method, url, timeout=timeout, headers=the_headers, **kwargs 92 | ) 93 | # If response.status_code is 4xx or 5xx, raise 94 | # requests.exceptions.HTTPError 95 | RateLimitErrorMsg = ( 96 | "For FREE plan, exceeding the quota limit; or for all plans, " 97 | "sending too many requests too fast and exceeding the rate limit " 98 | "- https://www.listennotes.com/api/faq/#faq17") 99 | if self.raise_exception: 100 | try: 101 | response.raise_for_status() 102 | except exceptions.ConnectionError: 103 | raise errors.APIConnectionError( 104 | "Failed to connect to Listen API.", response=response 105 | ) from None 106 | except exceptions.HTTPError as e: 107 | status_code = e.response.status_code 108 | if status_code == 404: 109 | # from None => suppress previous exception 110 | raise errors.NotFoundError( 111 | "Endpoint not exist, or podcast / episode not exist.", 112 | response=response, 113 | ) from None 114 | elif status_code == 401: 115 | raise errors.AuthenticationError( 116 | "Wrong api key, or your account is suspended.", 117 | response=response, 118 | ) from None 119 | elif status_code == 429: 120 | raise errors.RateLimitError( 121 | RateLimitErrorMsg, 122 | response=response, 123 | ) from None 124 | elif status_code == 400: 125 | raise errors.InvalidRequestError( 126 | "Something wrong on your end (client side errors)," 127 | " e.g., missing required parameters.", 128 | response=response, 129 | ) from None 130 | elif status_code >= 500: 131 | raise errors.ListenApiError( 132 | "Error on our end (unexpected server errors).", 133 | response=response, 134 | ) from None 135 | else: 136 | raise 137 | except Exception: 138 | raise errors.ListenApiError( 139 | "Unknown error. Please report to hello@listennotes.com", 140 | response=response, 141 | ) from None 142 | 143 | return response 144 | 145 | def delete(self, url, timeout=TIMEOUT, **kwargs): 146 | """Shortcut for DELETE request.""" 147 | return self.request("DELETE", url, timeout, **kwargs) 148 | 149 | def get(self, url, timeout=TIMEOUT, **kwargs): 150 | """Shortcut for GET request.""" 151 | return self.request("GET", url, timeout, **kwargs) 152 | 153 | def head(self, url, timeout=TIMEOUT, **kwargs): 154 | """Shortcut for HEAD request.""" 155 | return self.request("HEAD", url, timeout, **kwargs) 156 | 157 | def options(self, url, timeout=TIMEOUT, **kwargs): 158 | """Shortcut for OPTIONS request.""" 159 | return self.request("OPTIONS", url, timeout, **kwargs) 160 | 161 | def patch(self, url, timeout=TIMEOUT, **kwargs): 162 | """Shortcut for PATCH request.""" 163 | return self.request("PATCH", url, timeout, **kwargs) 164 | 165 | def post(self, url, timeout=TIMEOUT, **kwargs): 166 | """Shortcut for POST request.""" 167 | return self.request("POST", url, timeout, **kwargs) 168 | 169 | def put(self, url, timeout=TIMEOUT, **kwargs): 170 | """Shortcut for PUT request.""" 171 | return self.request("PUT", url, timeout, **kwargs) 172 | 173 | def trace(self, url, timeout=TIMEOUT, **kwargs): 174 | """Shortcut for TRACE request.""" 175 | return self.request("TRACE", url, timeout, **kwargs) 176 | 177 | def purge(self, url, timeout=TIMEOUT, **kwargs): 178 | """Shortcut for TRACE request.""" 179 | return self.request("PURGE", url, timeout, **kwargs) 180 | -------------------------------------------------------------------------------- /listennotes/podcast_api.py: -------------------------------------------------------------------------------- 1 | from listennotes import version, http_utils 2 | 3 | 4 | api_key = None 5 | api_base_prod = "https://listen-api.listennotes.com/api/v2" 6 | api_base_test = "https://listen-api-test.listennotes.com/api/v2" 7 | default_user_agent = "podcasts-api-python %s" % version.VERSION 8 | 9 | 10 | class Client(object): 11 | def __init__(self, api_key=None, user_agent=None, max_retries=None): 12 | self.api_base = api_base_prod if api_key else api_base_test 13 | 14 | self.request_headers = { 15 | "X-ListenAPI-Key": api_key, 16 | "User-Agent": user_agent if user_agent else default_user_agent, 17 | } 18 | 19 | request_kwargs = {} 20 | if max_retries: 21 | request_kwargs["max_retries"] = max_retries 22 | 23 | self.http_client = http_utils.Request(**request_kwargs) 24 | 25 | # 26 | # All endpoints 27 | # 28 | def search(self, **kwargs): 29 | return self.http_client.get( 30 | "%s/search" % self.api_base, 31 | params=kwargs, 32 | headers=self.request_headers, 33 | ) 34 | 35 | def typeahead(self, **kwargs): 36 | return self.http_client.get( 37 | "%s/typeahead" % self.api_base, 38 | params=kwargs, 39 | headers=self.request_headers, 40 | ) 41 | 42 | def search_episode_titles(self, **kwargs): 43 | return self.http_client.get( 44 | "%s/search_episode_titles" % self.api_base, 45 | params=kwargs, 46 | headers=self.request_headers, 47 | ) 48 | 49 | def spellcheck(self, **kwargs): 50 | return self.http_client.get( 51 | "%s/spellcheck" % self.api_base, 52 | params=kwargs, 53 | headers=self.request_headers, 54 | ) 55 | 56 | def fetch_related_searches(self, **kwargs): 57 | return self.http_client.get( 58 | "%s/related_searches" % self.api_base, 59 | params=kwargs, 60 | headers=self.request_headers, 61 | ) 62 | 63 | def fetch_trending_searches(self, **kwargs): 64 | return self.http_client.get( 65 | "%s/trending_searches" % self.api_base, 66 | params=kwargs, 67 | headers=self.request_headers, 68 | ) 69 | 70 | def fetch_best_podcasts(self, **kwargs): 71 | return self.http_client.get( 72 | "%s/best_podcasts" % self.api_base, 73 | params=kwargs, 74 | headers=self.request_headers, 75 | ) 76 | 77 | def fetch_podcast_by_id(self, **kwargs): 78 | podcast_id = kwargs.pop("id", None) 79 | return self.http_client.get( 80 | "%s/podcasts/%s" % (self.api_base, podcast_id), 81 | params=kwargs, 82 | headers=self.request_headers, 83 | ) 84 | 85 | def fetch_episode_by_id(self, **kwargs): 86 | episode_id = kwargs.pop("id", None) 87 | return self.http_client.get( 88 | "%s/episodes/%s" % (self.api_base, episode_id), 89 | params=kwargs, 90 | headers=self.request_headers, 91 | ) 92 | 93 | def batch_fetch_podcasts(self, **kwargs): 94 | return self.http_client.post( 95 | "%s/podcasts" % self.api_base, 96 | data=kwargs, 97 | headers=self.request_headers, 98 | ) 99 | 100 | def batch_fetch_episodes(self, **kwargs): 101 | return self.http_client.post( 102 | "%s/episodes" % self.api_base, 103 | data=kwargs, 104 | headers=self.request_headers, 105 | ) 106 | 107 | def fetch_curated_podcasts_list_by_id(self, **kwargs): 108 | curated_list_id = kwargs.pop("id", None) 109 | return self.http_client.get( 110 | "%s/curated_podcasts/%s" % (self.api_base, curated_list_id), 111 | params=kwargs, 112 | headers=self.request_headers, 113 | ) 114 | 115 | def fetch_curated_podcasts_lists(self, **kwargs): 116 | return self.http_client.get( 117 | "%s/curated_podcasts" % self.api_base, 118 | params=kwargs, 119 | headers=self.request_headers, 120 | ) 121 | 122 | def fetch_podcast_genres(self, **kwargs): 123 | return self.http_client.get( 124 | "%s/genres" % self.api_base, 125 | params=kwargs, 126 | headers=self.request_headers, 127 | ) 128 | 129 | def fetch_podcast_regions(self, **kwargs): 130 | return self.http_client.get( 131 | "%s/regions" % self.api_base, 132 | params=kwargs, 133 | headers=self.request_headers, 134 | ) 135 | 136 | def fetch_podcast_languages(self, **kwargs): 137 | return self.http_client.get( 138 | "%s/languages" % self.api_base, 139 | params=kwargs, 140 | headers=self.request_headers, 141 | ) 142 | 143 | def just_listen(self, **kwargs): 144 | return self.http_client.get( 145 | "%s/just_listen" % self.api_base, 146 | params=kwargs, 147 | headers=self.request_headers, 148 | ) 149 | 150 | def fetch_recommendations_for_podcast(self, **kwargs): 151 | podcast_id = kwargs.pop("id", None) 152 | return self.http_client.get( 153 | "%s/podcasts/%s/recommendations" % (self.api_base, podcast_id), 154 | params=kwargs, 155 | headers=self.request_headers, 156 | ) 157 | 158 | def fetch_recommendations_for_episode(self, **kwargs): 159 | episode_id = kwargs.pop("id", None) 160 | return self.http_client.get( 161 | "%s/episodes/%s/recommendations" % (self.api_base, episode_id), 162 | params=kwargs, 163 | headers=self.request_headers, 164 | ) 165 | 166 | def fetch_playlist_by_id(self, **kwargs): 167 | playlist_id = kwargs.pop("id", None) 168 | return self.http_client.get( 169 | "%s/playlists/%s" % (self.api_base, playlist_id), 170 | params=kwargs, 171 | headers=self.request_headers, 172 | ) 173 | 174 | def fetch_my_playlists(self, **kwargs): 175 | return self.http_client.get( 176 | "%s/playlists" % self.api_base, 177 | params=kwargs, 178 | headers=self.request_headers, 179 | ) 180 | 181 | def submit_podcast(self, **kwargs): 182 | return self.http_client.post( 183 | "%s/podcasts/submit" % self.api_base, 184 | data=kwargs, 185 | headers=self.request_headers, 186 | ) 187 | 188 | def delete_podcast(self, **kwargs): 189 | podcast_id = kwargs.pop("id", None) 190 | return self.http_client.delete( 191 | "%s/podcasts/%s" % (self.api_base, podcast_id), 192 | params=kwargs, 193 | headers=self.request_headers, 194 | ) 195 | 196 | def fetch_audience_for_podcast(self, **kwargs): 197 | podcast_id = kwargs.pop("id", None) 198 | return self.http_client.get( 199 | "%s/podcasts/%s/audience" % (self.api_base, podcast_id), 200 | params=kwargs, 201 | headers=self.request_headers, 202 | ) 203 | 204 | def fetch_podcasts_by_domain(self, **kwargs): 205 | domain_name = kwargs.pop("domain_name", None) 206 | return self.http_client.get( 207 | "%s/podcasts/domains/%s" % (self.api_base, domain_name), 208 | params=kwargs, 209 | headers=self.request_headers, 210 | ) 211 | -------------------------------------------------------------------------------- /listennotes/version.py: -------------------------------------------------------------------------------- 1 | VERSION = "1.1.6" 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length=79 3 | exclude = ''' 4 | /( 5 | \.eggs/ 6 | | \.git/ 7 | | \.tox/ 8 | | \.venv/ 9 | | _build/ 10 | | build/ 11 | | dist/ 12 | | venv/ 13 | ) 14 | ''' 15 | 16 | [build-system] 17 | requires = [ 18 | 'setuptools>=40.8.0', 19 | 'wheel', 20 | ] -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = LICENSE -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from codecs import open 3 | from setuptools import setup 4 | 5 | 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | os.chdir(here) 9 | 10 | with open(os.path.join(here, "README.md"), "r", encoding="utf-8") as fp: 11 | long_description = fp.read() 12 | 13 | version_contents = {} 14 | 15 | with open( 16 | os.path.join(here, "listennotes", "version.py"), encoding="utf-8" 17 | ) as f: 18 | exec(f.read(), version_contents) 19 | 20 | setup( 21 | name="podcast-api", 22 | version=version_contents.get("VERSION", "1.0.0"), 23 | description="Python bindings for the Listen Notes Podcast API", 24 | long_description=long_description, 25 | long_description_content_type="text/markdown", 26 | author="Listen Notes, Inc.", 27 | author_email="hello@listennotes.com", 28 | url="https://github.com/listennotes/podcast-api-python", 29 | license="MIT", 30 | keywords="listen notes podcast api", 31 | packages=["listennotes", "examples"], 32 | zip_safe=False, 33 | install_requires=[ 34 | 'requests >= 2.20; python_version >= "3.0"', 35 | "setuptools>=41.0.1", 36 | ], 37 | python_requires=">=3.10", 38 | project_urls={ 39 | "Bug Tracker": ( 40 | "https://github.com/listennotes/" "podcast-api-python/issues" 41 | ), 42 | "Documentation": "https://www.listennotes.com/api/docs/", 43 | "Source Code": "https://github.com/listennotes/podcast-api-python/", 44 | }, 45 | classifiers=[ 46 | "Development Status :: 5 - Production/Stable", 47 | "Intended Audience :: Developers", 48 | "License :: OSI Approved :: MIT License", 49 | "Operating System :: OS Independent", 50 | "Programming Language :: Python", 51 | "Programming Language :: Python :: 3", 52 | "Programming Language :: Python :: 3.10", 53 | "Programming Language :: Python :: 3.11", 54 | "Programming Language :: Python :: 3.12", 55 | "Programming Language :: Python :: 3.13", 56 | "Programming Language :: Python :: Implementation :: PyPy", 57 | "Topic :: Software Development :: Libraries :: Python Modules", 58 | ], 59 | ) 60 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ListenNotes/podcast-api-python/20537212af78f1de5612ec7c74ababa07419823b/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import parse_qs, urlparse 2 | 3 | from listennotes import podcast_api 4 | from listennotes.errors import AuthenticationError 5 | 6 | 7 | class TestClient(object): 8 | def test_set_apikey(self): 9 | client = podcast_api.Client() 10 | assert client.request_headers.get("X-ListenAPI-Key") is None 11 | 12 | api_key = "abcd" 13 | client = podcast_api.Client(api_key=api_key) 14 | assert client.request_headers.get("X-ListenAPI-Key") == api_key 15 | 16 | def test_search_with_mock(self): 17 | client = podcast_api.Client() 18 | term = "dummy" 19 | sort_by_date = 1 20 | response = client.search(q=term, sort_by_date=sort_by_date) 21 | assert len(response.json().get("results", [])) > 0 22 | assert response.request.method == "GET" 23 | url = urlparse(response.url) 24 | assert url.path == "/api/v2/search" 25 | params = parse_qs(url.query) 26 | assert params["q"][0] == term 27 | assert params["sort_by_date"][0] == "1" 28 | 29 | def test_search_with_authentication_error(self): 30 | api_key = "wrong key" 31 | client = podcast_api.Client(api_key=api_key) 32 | term = "dummy" 33 | sort_by_date = 1 34 | try: 35 | client.search(q=term, sort_by_date=sort_by_date) 36 | except AuthenticationError: 37 | pass 38 | except Exception: 39 | assert False 40 | else: 41 | assert False 42 | 43 | def test_search_episode_titles_with_mock(self): 44 | client = podcast_api.Client() 45 | term = "dummy2" 46 | response = client.search_episode_titles( 47 | q=term, podcast_id='0cdaa63b905b4de3861554669a6a3dd1') 48 | assert len(response.json().get("results", [])) > 0 49 | assert response.request.method == "GET" 50 | url = urlparse(response.url) 51 | assert url.path == "/api/v2/search_episode_titles" 52 | params = parse_qs(url.query) 53 | assert params["q"][0] == term 54 | assert params["podcast_id"][0] == "0cdaa63b905b4de3861554669a6a3dd1" 55 | 56 | def test_typeahead_with_mock(self): 57 | client = podcast_api.Client() 58 | term = "dummy" 59 | show_podcasts = 1 60 | response = client.typeahead(q=term, show_podcasts=show_podcasts) 61 | assert len(response.json().get("terms", [])) > 0 62 | assert response.request.method == "GET" 63 | url = urlparse(response.url) 64 | assert url.path == "/api/v2/typeahead" 65 | params = parse_qs(url.query) 66 | assert params["q"][0] == term 67 | assert params["show_podcasts"][0] == "1" 68 | 69 | def test_spellcheck_with_mock(self): 70 | client = podcast_api.Client() 71 | term = "dummy" 72 | response = client.spellcheck(q=term) 73 | assert len(response.json().get("tokens", [])) > 0 74 | assert response.request.method == "GET" 75 | url = urlparse(response.url) 76 | assert url.path == "/api/v2/spellcheck" 77 | params = parse_qs(url.query) 78 | assert params["q"][0] == term 79 | 80 | def test_related_searches_with_mock(self): 81 | client = podcast_api.Client() 82 | term = "dummy" 83 | response = client.fetch_related_searches(q=term) 84 | assert len(response.json().get("terms", [])) > 0 85 | assert response.request.method == "GET" 86 | url = urlparse(response.url) 87 | assert url.path == "/api/v2/related_searches" 88 | params = parse_qs(url.query) 89 | assert params["q"][0] == term 90 | 91 | def test_trending_searches_with_mock(self): 92 | client = podcast_api.Client() 93 | response = client.fetch_trending_searches() 94 | assert len(response.json().get("terms", [])) > 0 95 | assert response.request.method == "GET" 96 | url = urlparse(response.url) 97 | assert url.path == "/api/v2/trending_searches" 98 | 99 | def test_fetch_best_podcasts_with_mock(self): 100 | client = podcast_api.Client() 101 | genre_id = 23 102 | response = client.fetch_best_podcasts(genre_id=genre_id) 103 | assert response.json().get("total", 0) > 0 104 | assert response.request.method == "GET" 105 | url = urlparse(response.url) 106 | assert url.path == "/api/v2/best_podcasts" 107 | params = parse_qs(url.query) 108 | assert params["genre_id"][0] == str(genre_id) 109 | 110 | def test_fetch_podcast_by_id_with_mock(self): 111 | client = podcast_api.Client() 112 | podcast_id = "asdfsdaf" 113 | response = client.fetch_podcast_by_id(id=podcast_id) 114 | assert len(response.json().get("episodes", [])) > 0 115 | assert response.request.method == "GET" 116 | url = urlparse(response.url) 117 | assert url.path == "/api/v2/podcasts/%s" % podcast_id 118 | 119 | def test_fetch_episode_by_id_with_mock(self): 120 | client = podcast_api.Client() 121 | episode_id = "asdfsdaf" 122 | response = client.fetch_episode_by_id(id=episode_id) 123 | assert len(response.json().get("podcast", {}).get("rss")) > 0 124 | assert response.request.method == "GET" 125 | url = urlparse(response.url) 126 | assert url.path == "/api/v2/episodes/%s" % episode_id 127 | 128 | def test_batch_fetch_podcasts_with_mock(self): 129 | client = podcast_api.Client() 130 | ids = "996,777,888,1000" 131 | response = client.batch_fetch_podcasts(ids=ids) 132 | assert parse_qs(response.request.body)["ids"][0] == ids 133 | assert len(response.json().get("podcasts", [])) > 0 134 | assert response.request.method == "POST" 135 | url = urlparse(response.url) 136 | assert url.path == "/api/v2/podcasts" 137 | 138 | def test_batch_fetch_episodes_with_mock(self): 139 | client = podcast_api.Client() 140 | ids = "996,777,888,100220" 141 | response = client.batch_fetch_episodes(ids=ids) 142 | assert parse_qs(response.request.body)["ids"][0] == ids 143 | assert len(response.json().get("episodes", [])) > 0 144 | assert response.request.method == "POST" 145 | url = urlparse(response.url) 146 | assert url.path == "/api/v2/episodes" 147 | 148 | def test_fetch_curated_podcasts_list_by_id_with_mock(self): 149 | client = podcast_api.Client() 150 | curated_list_id = "asdfsdaf" 151 | response = client.fetch_curated_podcasts_list_by_id(id=curated_list_id) 152 | assert len(response.json().get("podcasts", [])) > 0 153 | assert response.request.method == "GET" 154 | url = urlparse(response.url) 155 | assert url.path == "/api/v2/curated_podcasts/%s" % curated_list_id 156 | 157 | def test_fetch_curated_podcasts_lists_with_mock(self): 158 | client = podcast_api.Client() 159 | page = 2 160 | response = client.fetch_curated_podcasts_lists(page=page) 161 | assert response.json().get("total") > 0 162 | assert response.request.method == "GET" 163 | url = urlparse(response.url) 164 | params = parse_qs(url.query) 165 | assert params["page"][0] == str(page) 166 | assert url.path == "/api/v2/curated_podcasts" 167 | 168 | def test_fetch_podcast_genres_with_mock(self): 169 | client = podcast_api.Client() 170 | top_level_only = 1 171 | response = client.fetch_podcast_genres(top_level_only=top_level_only) 172 | assert len(response.json().get("genres", [])) > 0 173 | assert response.request.method == "GET" 174 | url = urlparse(response.url) 175 | params = parse_qs(url.query) 176 | assert params["top_level_only"][0] == str(top_level_only) 177 | assert url.path == "/api/v2/genres" 178 | 179 | def test_fetch_podcast_regions_with_mock(self): 180 | client = podcast_api.Client() 181 | response = client.fetch_podcast_regions() 182 | assert len(response.json().get("regions", {}).keys()) > 0 183 | assert response.request.method == "GET" 184 | url = urlparse(response.url) 185 | assert url.path == "/api/v2/regions" 186 | 187 | def test_fetch_podcast_languages_with_mock(self): 188 | client = podcast_api.Client() 189 | response = client.fetch_podcast_languages() 190 | assert len(response.json().get("languages", [])) > 0 191 | assert response.request.method == "GET" 192 | url = urlparse(response.url) 193 | assert url.path == "/api/v2/languages" 194 | 195 | def test_just_listen_with_mock(self): 196 | client = podcast_api.Client() 197 | response = client.just_listen() 198 | assert response.json().get("audio_length_sec", 0) > 0 199 | assert response.request.method == "GET" 200 | url = urlparse(response.url) 201 | assert url.path == "/api/v2/just_listen" 202 | 203 | def test_fetch_recommendations_for_podcast_with_mock(self): 204 | client = podcast_api.Client() 205 | podcast_id = "adfsddf" 206 | response = client.fetch_recommendations_for_podcast(id=podcast_id) 207 | assert len(response.json().get("recommendations", [])) > 0 208 | assert response.request.method == "GET" 209 | url = urlparse(response.url) 210 | assert url.path == "/api/v2/podcasts/%s/recommendations" % podcast_id 211 | 212 | def test_fetch_recommendations_for_episode_with_mock(self): 213 | client = podcast_api.Client() 214 | episode_id = "adfsddf" 215 | response = client.fetch_recommendations_for_episode(id=episode_id) 216 | assert len(response.json().get("recommendations", [])) > 0 217 | assert response.request.method == "GET" 218 | url = urlparse(response.url) 219 | assert url.path == "/api/v2/episodes/%s/recommendations" % episode_id 220 | 221 | def test_fetch_playlist_by_id_with_mock(self): 222 | client = podcast_api.Client() 223 | playlist_id = "adfsddf" 224 | response = client.fetch_playlist_by_id(id=playlist_id) 225 | assert len(response.json().get("items", [])) > 0 226 | assert response.request.method == "GET" 227 | url = urlparse(response.url) 228 | assert url.path == "/api/v2/playlists/%s" % playlist_id 229 | 230 | def test_fetch_my_playlists_with_mock(self): 231 | client = podcast_api.Client() 232 | page = 2 233 | response = client.fetch_my_playlists(page=page) 234 | assert len(response.json().get("playlists", [])) > 0 235 | assert response.request.method == "GET" 236 | url = urlparse(response.url) 237 | assert url.path == "/api/v2/playlists" 238 | 239 | def test_submit_podcast_with_mock(self): 240 | client = podcast_api.Client() 241 | rss = "http://myrss.com/rss" 242 | response = client.submit_podcast(rss=rss) 243 | assert parse_qs(response.request.body)["rss"][0] == rss 244 | assert len(response.json().get("status", "")) > 0 245 | assert response.request.method == "POST" 246 | url = urlparse(response.url) 247 | assert url.path == "/api/v2/podcasts/submit" 248 | 249 | def test_delete_podcast_with_mock(self): 250 | client = podcast_api.Client() 251 | podcast_id = "asdfasdfdf" 252 | response = client.delete_podcast(id=podcast_id) 253 | assert len(response.json().get("status", "")) > 0 254 | assert response.request.method == "DELETE" 255 | url = urlparse(response.url) 256 | assert url.path == "/api/v2/podcasts/%s" % podcast_id 257 | 258 | def test_fetch_audience_for_podcast_with_mock(self): 259 | client = podcast_api.Client() 260 | podcast_id = "adfsddf" 261 | response = client.fetch_audience_for_podcast(id=podcast_id) 262 | assert len(response.json().get("by_regions", [])) > 0 263 | assert response.request.method == "GET" 264 | url = urlparse(response.url) 265 | assert url.path == "/api/v2/podcasts/%s/audience" % podcast_id 266 | 267 | def test_fetch_podcasts_by_domain_with_mock(self): 268 | client = podcast_api.Client() 269 | domain_name = "nytimes.com" 270 | response = client.fetch_podcasts_by_domain(domain_name=domain_name, page=3) 271 | assert len(response.json().get("podcasts", [])) > 0 272 | assert response.request.method == "GET" 273 | url = urlparse(response.url) 274 | params = parse_qs(url.query) 275 | assert params["page"][0] == '3' 276 | assert url.path == "/api/v2/podcasts/domains/%s" % domain_name 277 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = 8 | fmt 9 | lint 10 | py{310,311,312,313} 11 | skip_missing_interpreters = true 12 | 13 | [tool:pytest] 14 | testpaths = tests 15 | addopts = 16 | --cov-report=term-missing 17 | 18 | [testenv] 19 | description = run the unit tests under {basepython} 20 | setenv = 21 | COVERAGE_FILE = {toxworkdir}/.coverage.{envname} 22 | deps = 23 | coverage >= 5 24 | py{310,39,38,37}: pytest >= 6.0.0 25 | pytest-cov >= 2.8.1, < 2.11.0 26 | pytest-mock >= 2.0.0 27 | pytest-xdist >= 1.31.0 28 | commands = pytest --cov {posargs:-n auto} 29 | passenv = LDFLAGS,CFLAGS 30 | 31 | [testenv:fmt] 32 | description = run code formatting using black 33 | basepython = python3.10 34 | deps = black==22.3.0 35 | commands = black . {posargs} 36 | skip_install = true 37 | 38 | [testenv:lint] 39 | description = run static analysis and style check using flake8 40 | basepython = python3.10 41 | deps = flake8 42 | commands = python -m flake8 --show-source listennotes tests setup.py --max-line-length=100 43 | skip_install = true 44 | --------------------------------------------------------------------------------