diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 6bc76779..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,161 +0,0 @@ -############################################################################### -### Reusable template definitions -############################################################################### -.py_3_6_container: &py_3_6_container - docker: - # CircleCI Python images available at: https://hub.docker.com/r/circleci/python/ - - image: circleci/python:3.6 - -.py_3_7_container: &py_3_7_container - docker: - # CircleCI Python images available at: https://hub.docker.com/r/circleci/python/ - - image: circleci/python:3.7 - -.py_3_8_container: &py_3_8_container - docker: - # CircleCI Python images available at: https://hub.docker.com/r/circleci/python/ - - image: circleci/python:3.8 - -.py_3_9_container: &py_3_9_container - docker: - # CircleCI Python images available at: https://hub.docker.com/r/circleci/python/ - - image: circleci/python:3.9 - -.py_3_10_container: &py_3_10_container - docker: - # CircleCI Python images available at: https://hub.docker.com/r/circleci/python/ - - image: circleci/python:3.10 - -.pytest: &pytest - run: - name: Run unit tests - command: make test - -.flake8: &flake8 - run: - name: Run flake8 - command: make style - -.test_steps: &test_steps - steps: - - checkout - - <<: *pytest - - store_artifacts: # upload test summary for display in Artifacts - path: ${TEST_RESULTS} - destination: raw-test-output - - store_test_results: # upload test results for display in Test Summary - path: /tmp/test-results - -.lint_steps: &lint_steps - steps: - - checkout - - <<: *flake8 - - store_artifacts: # upload lint summary for display in Artifacts - path: ${LINT_RESULTS} - destination: raw-lint-output - -.common: &common - # Run multiple jobs in parallel. - parallelism: 2 - - # Declare the working directory for the job. - working_directory: ~/PyTrakt - - # Define the environment variables to be injected into the build itself. - environment: - TEST_RESULTS: /tmp/test-results # path to where test results will be saved - LINT_RESULTS: /tmp/lint-results # path to where lint results will be saved - -version: 2 - -############################################################################### -### Job Definitions -############################################################################### -jobs: - ############################################################################# - ### Python 3.6 Jobs - ############################################################################# - test.3.6: - <<: *py_3_6_container - <<: *common - <<: *test_steps - lint.3.6: - <<: *py_3_6_container - <<: *common - <<: *lint_steps - - ############################################################################# - ### Python 3.7 Jobs - ############################################################################# - test.3.7: - <<: *py_3_7_container - <<: *common - <<: *test_steps - lint.3.7: - <<: *py_3_7_container - <<: *common - <<: *lint_steps - - ############################################################################# - ### Python 3.8 Jobs - ############################################################################# - test.3.8: - <<: *py_3_8_container - <<: *common - <<: *test_steps - lint.3.8: - <<: *py_3_8_container - <<: *common - <<: *lint_steps - - ############################################################################# - ### Python 3.9 Jobs - ############################################################################# - test.3.9: - <<: *py_3_9_container - <<: *common - <<: *test_steps - lint.3.9: - <<: *py_3_9_container - <<: *common - <<: *lint_steps - - ############################################################################# - ### Python 3.10 Jobs - ############################################################################# - test.3.10: - <<: *py_3_10_container - <<: *common - <<: *test_steps - lint.3.10: - <<: *py_3_10_container - <<: *common - <<: *lint_steps - -workflows: - version: 2 - - py-3.6-verify: - jobs: - - test.3.6 - - lint.3.6 - - py-3.7-verify: - jobs: - - test.3.7 - - lint.3.7 - - py-3.8-verify: - jobs: - - test.3.8 - - lint.3.8 - - py-3.9-verify: - jobs: - - test.3.9 - - lint.3.9 - - py-3.10-verify: - jobs: - - test.3.10 - - lint.3.10 diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml new file mode 100644 index 00000000..c872383e --- /dev/null +++ b/.github/workflows/pypi.yml @@ -0,0 +1,64 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: +# https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: PyPI + +on: + workflow_dispatch: ~ + release: + types: [published] + push: + tags: + - '*.*.*' + +env: + DEFAULT_PYTHON: "3.10" + +jobs: + pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + + - name: Set version variable + id: version + run: | + if [[ "${GITHUB_REF#refs/heads/}" = "${GITHUB_REF}" ]]; then + APP_VERSION=${GITHUB_REF#refs/tags/} + else + git fetch --tags --unshallow + version=$(git describe --tags --abbrev=0) + subver=${{ github.run_number }} + APP_VERSION=$version.post$subver + fi + echo "version=$APP_VERSION" >> $GITHUB_OUTPUT + + - name: Install dependencies and build + env: + APP_VERSION: ${{ steps.version.outputs.version }} + run: | + # Setup version + set -x + echo "__version__ = '$APP_VERSION'" > trakt/__version__.py + cat trakt/__version__.py + python -c "from trakt import __version__; print(__version__)" + + # Build the package + python -m pip install --upgrade build + python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_TOKEN }} + +# vim:ts=2:sw=2:et diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..e94c4efe --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-20.04 + name: Python ${{ matrix.python }} + strategy: + matrix: + python: + - "3.6" + - "3.7" + - "3.8" + - "3.9" + - "3.10" + + steps: + - name: Check out source repository + uses: actions/checkout@v3 + - name: Set up Python environment + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + cache: pip + - name: Install dependencies (pip) + run: python -m pip install -r requirements.txt -r testing-requirements.txt + - name: Test + run: make test + +# vim:ts=2:sw=2:et diff --git a/README.rst b/README.rst index b387889a..ceac4759 100644 --- a/README.rst +++ b/README.rst @@ -1,15 +1,16 @@ PyTrakt ======= -.. image:: https://circleci.com/gh/moogar0880/PyTrakt/tree/master.svg?style=svg - :target: https://circleci.com/gh/moogar0880/PyTrakt/tree/master - :alt: CircleCI Status -.. image:: https://img.shields.io/pypi/dm/trakt.svg - :target: https://pypi.python.org/pypi/trakt +.. image:: https://github.com/glensc/python-pytrakt/actions/workflows/test.yml/badge.svg + :target: https://github.com/glensc/python-pytrakt/actions + :alt: CI Status + +.. image:: https://img.shields.io/pypi/dm/pytrakt.svg + :target: https://pypi.org/project/pytrakt/ :alt: Downloads -.. image:: https://img.shields.io/pypi/l/trakt.svg - :target: https://pypi.python.org/pypi/trakt/ +.. image:: https://img.shields.io/pypi/l/pytrakt.svg + :target: https://pypi.org/project/pytrakt/ :alt: License This module is designed to be a Pythonic interface to the `Trakt.tv `_. @@ -30,23 +31,23 @@ Install Via Pip ^^^^^^^^^^^^^^^ To install with `pip `_, just run this in your terminal:: - $ pip install trakt + $ pip install pytrakt Get the code ^^^^^^^^^^^^ -trakt is available on `GitHub `_. +trakt is available on `GitHub `_. You can either clone the public repository:: - $ git clone git://github.com/moogar0880/PyTrakt.git + $ git clone git://github.com/glensc/python-pytrakt.git -Download the `tarball `_:: +Download the `tarball `_:: - $ curl -OL https://github.com/moogar0880/PyTrakt/tarball/master + $ curl -OL https://github.com/glensc/python-pytrakt/tarball/main -Or, download the `zipball `_:: +Or, download the `zipball `_:: - $ curl -OL https://github.com/moogar0880/PyTrakt/zipball/master + $ curl -OL https://github.com/glensc/python-pytrakt/zipball/main Once you have a copy of the source, you can embed it in your Python package, or install it into your site-packages easily:: @@ -58,7 +59,7 @@ Contributing Pull requests are graciously accepted. Any pull request should not break any tests and should pass `flake8` style checks (unless otherwise warranted). Additionally the user opening the Pull Request should ensure that their username and a link to -their GitHub page appears in `CONTRIBUTORS.md `_. +their GitHub page appears in `CONTRIBUTORS.md `_. TODO diff --git a/docs/getstarted.rst b/docs/getstarted.rst index 85808f74..923dee7d 100644 --- a/docs/getstarted.rst +++ b/docs/getstarted.rst @@ -86,8 +86,6 @@ Should you choose to store your credentials in another way and not to set the `store` flag, you will need to ensure that your application applies the following settings before attempting to interact with Trakt -* `trakt.core.api_key` - * Note: api_key is deprecated in favor of OAUTH_TOKEN and will go away with the next major release * `trakt.core.OAUTH_TOKEN` * `trakt.core.CLIENT_ID` * `trakt.core.CLIENT_SECRET` diff --git a/docs/index.rst b/docs/index.rst index 9e3a7529..75cdfe75 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,19 +28,19 @@ To install with `pip `_, just run this in your te Get the code ^^^^^^^^^^^^ -trakt is available on `GitHub `_. +trakt is available on `GitHub `_. You can either clone the public repository:: - $ git clone git://github.com/moogar0880/PyTrakt.git + $ git clone git://github.com/glensc/python-pytrakt.git -Download the `tarball `_:: +Download the `tarball `_:: - $ curl -OL https://github.com/moogar0880/PyTrakt/tarball/master + $ curl -OL https://github.com/glensc/python-pytrakt/tarball/master -Or, download the `zipball `_:: +Or, download the `zipball `_:: - $ curl -OL https://github.com/moogar0880/PyTrakt/zipball/master + $ curl -OL https://github.com/glensc/python-pytrakt/zipball/main Once you have a copy of the source, you can embed it in your Python package, or install it into your site-packages easily:: diff --git a/requirements.txt b/requirements.txt index fa971311..b3cf4647 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -requests>=2.25 +deprecated~=1.2.13 requests-oauthlib>=1.3 +requests>=2.25 diff --git a/setup.py b/setup.py index 847e11a2..3885086d 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import trakt -__author__ = 'Jon Nappi' +__author__ = 'Elan Ruusamäe, Jon Nappi' with open('README.rst') as f: readme = f.read() @@ -16,13 +16,13 @@ 'Trakt.tv REST API.') setup( - name='trakt', + name='pytrakt', version=trakt.__version__, description=description, long_description='\n'.join([readme, history]), - author='Jonathan Nappi', - author_email='moogar0880@gmail.com', - url='https://github.com/moogar0880/PyTrakt', + author='Elan Ruusamäe', + author_email='glen@pld-linux.org', + url='https://github.com/glensc/python-pytrakt', packages=packages, install_requires=requires, license='Apache 2.0', diff --git a/tests/conftest.py b/tests/conftest.py index 16129eeb..cdf01317 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,7 +27,7 @@ class MockCore(trakt.core.Core): def __init__(self, *args, **kwargs): - super(MockCore, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.mock_data = {} for mock_file in MOCK_DATA_FILES: with open(mock_file, encoding='utf-8') as f: @@ -40,7 +40,11 @@ def _handle_request(self, method, url, data=None): # use a deepcopy of the mocked data to ensure clean responses on every # request. this prevents rewrites to JSON responses from persisting method_responses = deepcopy(self.mock_data).get(uri, {}) - return method_responses.get(method.upper()) + result = method_responses.get(method.upper()) + if result is None: + print(f"Missing mock for {method.upper()} {trakt.core.BASE_URL}{uri}") + + return result """Override utility functions from trakt.core to use an underlying MockCore diff --git a/tests/mock_data/seasons.json b/tests/mock_data/seasons.json index 9873856f..c487ce25 100644 --- a/tests/mock_data/seasons.json +++ b/tests/mock_data/seasons.json @@ -17,6 +17,184 @@ {"number":4,"ids":{"trakt":5,"tvdb":522882,"tmdb":3628,"tvrage":null}} ] }, + "shows/the-flash-2014/seasons?extended=episodes": { + "GET": [ + { + "number": 0, + "ids": { "trakt": 126556, "tvdb": 578365, "tmdb": 79954, "tvrage": null }, + "episodes": [ + { + "season": 0, + "number": 1, + "title": "Chronicles Of Cisco (1)", + "ids": { "trakt": 2221945, "tvdb": 7444560, "imdb": "tt5656730", "tmdb": 1220887, "tvrage": null } + }, + { + "season": 0, + "number": 2, + "title": "Chronicles Of Cisco (2)", + "ids": { "trakt": 2221946, "tvdb": 7444566, "imdb": "tt5666962", "tmdb": 1220888, "tvrage": null } + } + ] + }, + { + "number": 1, + "ids": { "trakt": 61430, "tvdb": 578373, "tmdb": 60523, "tvrage": 36939 }, + "episodes": [ + { + "season": 1, + "number": 1, + "title": "Pilot", + "ids": { "trakt": 962074, "tvdb": 4812524, "imdb": "tt3187092", "tmdb": 977122, "tvrage": 1065564472 } + }, + { + "season": 1, + "number": 2, + "title": "Fastest Man Alive", + "ids": { "trakt": 962075, "tvdb": 4929322, "imdb": "tt3819518", "tmdb": 1005650, "tvrage": 1065603573 } + }, + { + "season": 1, + "number": 3, + "title": "Things You Can't Outrun", + "ids": { "trakt": 962076, "tvdb": 4929325, "imdb": "tt3826166", "tmdb": 1005651, "tvrage": 1065603574 } + }, + { + "season": 1, + "number": 4, + "title": "Going Rogue", + "ids": { "trakt": 962077, "tvdb": 4936770, "imdb": "tt3881958", "tmdb": 1005652, "tvrage": 1065609025 } + }, + { + "season": 1, + "number": 5, + "title": "Plastique", + "ids": { "trakt": 999423, "tvdb": 5025023, "imdb": "tt3887830", "tmdb": 1010677, "tvrage": 1065625291 } + }, + { + "season": 1, + "number": 6, + "title": "The Flash is Born", + "ids": { "trakt": 999424, "tvdb": 5028737, "imdb": "tt3920288", "tmdb": 1010678, "tvrage": 1065702880 } + }, + { + "season": 1, + "number": 7, + "title": "Power Outage", + "ids": { "trakt": 999426, "tvdb": 5028738, "imdb": "tt3922506", "tmdb": 1010679, "tvrage": 1065709260 } + }, + { + "season": 1, + "number": 8, + "title": "Flash vs. Arrow (I)", + "ids": { "trakt": 999428, "tvdb": 5028739, "imdb": "tt3899320", "tmdb": 1010680, "tvrage": 1065710605 } + }, + { + "season": 1, + "number": 9, + "title": "The Man in the Yellow Suit", + "ids": { "trakt": 999429, "tvdb": 5042818, "imdb": "tt4017786", "tmdb": 1018354, "tvrage": 1065711822 } + }, + { + "season": 1, + "number": 10, + "title": "Revenge of the Rogues", + "ids": { "trakt": 999431, "tvdb": 5052260, "imdb": "tt4016102", "tmdb": 1018355, "tvrage": 1065644215 } + }, + { + "season": 1, + "number": 11, + "title": "The Sound and the Fury", + "ids": { "trakt": 1701689, "tvdb": 5073549, "imdb": "tt4111294", "tmdb": 1037712, "tvrage": 1065735300 } + }, + { + "season": 1, + "number": 12, + "title": "Crazy for You", + "ids": { "trakt": 1701690, "tvdb": 5073556, "imdb": "tt4105618", "tmdb": 1037713, "tvrage": 1065683600 } + }, + { + "season": 1, + "number": 13, + "title": "The Nuclear Man", + "ids": { "trakt": 1701691, "tvdb": 5088525, "imdb": "tt4138324", "tmdb": 1037714, "tvrage": 1065735301 } + }, + { + "season": 1, + "number": 14, + "title": "Fallout", + "ids": { "trakt": 1718914, "tvdb": 5104336, "imdb": "tt4138326", "tmdb": 1039988, "tvrage": 1065738929 } + }, + { + "season": 1, + "number": 15, + "title": "Out of Time", + "ids": { "trakt": 1718915, "tvdb": 5104337, "imdb": "tt4138338", "tmdb": 1039989, "tvrage": 1065738930 } + }, + { + "season": 1, + "number": 16, + "title": "Rogue Time", + "ids": { "trakt": 1718916, "tvdb": 5104338, "imdb": "tt4138340", "tmdb": 1039990, "tvrage": 1065738931 } + }, + { + "season": 1, + "number": 17, + "title": "Tricksters", + "ids": { "trakt": 1718917, "tvdb": 5104339, "imdb": "tt4138344", "tmdb": 1039991, "tvrage": 1065728023 } + }, + { + "season": 1, + "number": 18, + "title": "All-Star Team Up", + "ids": { "trakt": 1725151, "tvdb": 5110414, "imdb": "tt4138352", "tmdb": 1047470, "tvrage": 1065747417 } + }, + { + "season": 1, + "number": 19, + "title": "Who Is Harrison Wells?", + "ids": { "trakt": 1765383, "tvdb": 5166508, "imdb": "tt4138350", "tmdb": 1051229, "tvrage": null } + }, + { + "season": 1, + "number": 20, + "title": "The Trap", + "ids": { "trakt": 1765384, "tvdb": 5166509, "imdb": "tt4138356", "tmdb": 1051230, "tvrage": null } + }, + { + "season": 1, + "number": 21, + "title": "Grodd Lives", + "ids": { "trakt": 1765385, "tvdb": 5166510, "imdb": "tt4138376", "tmdb": 1051231, "tvrage": null } + }, + { + "season": 1, + "number": 22, + "title": "Rogue Air", + "ids": { "trakt": 1765386, "tvdb": 5163053, "imdb": "tt4138378", "tmdb": 1051232, "tvrage": null } + }, + { + "season": 1, + "number": 23, + "title": "Fast Enough", + "ids": { "trakt": 1798337, "tvdb": 5166511, "imdb": "tt4146568", "tmdb": 1051233, "tvrage": null } + } + ] + }, + { + "number": 2, + "ids": { "trakt": 110984, "tvdb": 626964, "tmdb": 66922, "tvrage": null }, + "episodes": [ + { + "season": 2, + "number": 1, + "title": "The Man Who Saved Central City", + "ids": { "trakt": 1866102, "tvdb": 5260562, "imdb": "tt4346792", "tmdb": 1063859, "tvrage": null } + } + ] + } + ] + }, "shows/game-of-thrones/seasons?extended=images": { "GET": [ {"number":0,"ids":{"trakt":1,"tvdb":137481,"tmdb":3627,"tvrage":null},"images":{"poster":{"full":"https://walter.trakt.us/images/seasons/000/002/145/posters/original/41221f3712.jpg?1409351965","medium":"https://walter.trakt.us/images/seasons/000/002/145/posters/medium/41221f3712.jpg?1409351965","thumb":"https://walter.trakt.us/images/seasons/000/002/145/posters/thumb/41221f3712.jpg?1409351965"},"thumb":{"full":"https://walter.trakt.us/images/seasons/000/002/145/thumbs/original/c41b46dd09.jpg?1409351965"}}}, diff --git a/tests/mock_data/shows.json b/tests/mock_data/shows.json index e98784c1..8d728987 100644 --- a/tests/mock_data/shows.json +++ b/tests/mock_data/shows.json @@ -149,6 +149,89 @@ {"username":"sean","private":false,"name":"Sean Rudford","vip":true,"vip_ep":false} ] }, + "shows/game-of-thrones/progress/collection": { + "GET": { + "aired": 2, + "completed": 2, + "last_collected_at": "2020-08-25T20:10:33.000Z", + "seasons": [ + { + "number": 1, + "title": null, + "aired": 2, + "completed": 2, + "episodes": [ + { + "number": 1, + "completed": true, + "collected_at": "2011-12-18T12:21:20.000Z" + }, + { + "number": 2, + "completed": true, + "collected_at": "2011-12-18T14:18:22.000Z" + } + ] + } + ], + "hidden_seasons": [], + "next_episode": null, + "last_episode": { + "season": 1, + "number": 1, + "title": "Winter Is Coming", + "ids": { + "trakt": 73640, + "tvdb": 3254641, + "imdb": "tt1480055", + "tmdb": 63056, + "tvrage": 1065008299 + } + } + } + }, + "shows/game-of-thrones/progress/watched": { + "GET": { + "aired": 2, + "completed": 0, + "last_watched_at": null, + "reset_at": null, + "seasons": [ + { + "number": 1, + "title": null, + "aired": 2, + "completed": 0, + "episodes": [ + { + "number": 1, + "completed": false, + "last_watched_at": null + }, + { + "number": 2, + "completed": false, + "last_watched_at": null + } + ] + } + ], + "hidden_seasons": [], + "next_episode": { + "season": 1, + "number": 1, + "title": "Winter Is Coming", + "ids": { + "trakt": 73640, + "tvdb": 3254641, + "imdb": "tt1480055", + "tmdb": 63056, + "tvrage": 1065008299 + } + }, + "last_episode": null + } + }, "shows/the-walking-dead?extended=full": { "GET": {"title":"The Walking Dead","year":2010,"ids":{"trakt":1393,"slug":"the-walking-dead","tvdb":153021,"imdb":"tt1520211","tmdb":1402,"tvrage":25056},"overview":"The world we knew is gone. An epidemic of apocalyptic proportions has swept the globe causing the dead to rise and feed on the living. In a matter of months society has crumbled. In a world ruled by the dead, we are forced to finally start living. Based on a comic book series of the same name by Robert Kirkman, this AMC project focuses on the world after a zombie apocalypse. The series follows a police officer, Rick Grimes, who wakes up from a coma to find the world ravaged with zombies. Looking for his family, he and a group of survivors attempt to battle against the zombies in order to stay alive.\n","first_aired":"2010-10-31T07:00:00.000Z","airs":{"day":"Sunday","time":"21:00","timezone":"America/New_York"},"runtime":60,"certification":"TV-MA","network":"AMC","country":"us","trailer":"http://youtube.com/watch?v=R1v0uFms68U","homepage":"http://www.amctv.com/shows/the-walking-dead/","status":"returning series","rating":8.62829,"votes":34161,"updated_at":"2016-04-24T10:50:26.000Z","language":"en","available_translations":["en","de","sv","it","pt","tr","ru","zh","fr","es","nl","pl","bg","el","hu","ja","he","da","cs","ko","cn","bs","hr","fa","lt","lv","ro","sr","vi","et","uk","fi","th","id","ms"],"genres":["drama","action","horror","suspense"],"aired_episodes":83} } diff --git a/tests/mock_data/users.json b/tests/mock_data/users.json index f0222c3b..3e9176e2 100644 --- a/tests/mock_data/users.json +++ b/tests/mock_data/users.json @@ -47,6 +47,7 @@ "name":"Star Wars in NEW machete order", "description":"Some descriptive text", "privacy":"private", + "type":"personal", "display_numbers":true, "allow_comments":false, "updated_at":"2014-10-11T17:00:54.000Z", @@ -188,7 +189,7 @@ {"type":"show","show":{"title":"Breaking Bad","year":2008,"ids":{"trakt":1,"slug":"breaking-bad","tvdb":81189,"imdb":"tt0903747","tmdb":1396,"tvrage":18164}},"comment":{"id":199,"comment":"Skyler, I AM THE DANGER.","spoiler":false,"review":false,"parent_id":0,"created_at":"2015-02-18T06:02:30.000Z","replies":0,"likes":0,"user_rating":10,"user":{"username":"justin","private":false,"name":"Justin N.","vip":true,"vip_ep":false}}}, {"type":"season","season":{"number":1,"ids":{"trakt":3958,"tvdb":274431,"tmdb":60394,"tvrage":38049}},"show":{"title":"Gotham","year":2014,"ids":{"trakt":869,"slug":"gotham","tvdb":274431,"imdb":"tt3749900","tmdb":60708,"tvrage":38049}},"comment":{"id":220,"comment":"Kicking off season 1 for a new Batman show.","spoiler":false,"review":false,"parent_id":0,"created_at":"2015-04-21T06:53:25.000Z","replies":0,"likes":0,"user_rating":8,"user":{"username":"justin","private":false,"name":"Justin N.","vip":true,"vip_ep":false}}}, {"type":"episode","episode":{"season":1,"number":1,"title":"Jim Gordon","ids":{"trakt":63958,"tvdb":4768720,"imdb":"tt3216414","tmdb":975968,"tvrage":1065564827}},"show":{"title":"Gotham","year":2014,"ids":{"trakt":869,"slug":"gotham","tvdb":274431,"imdb":"tt3749900","tmdb":60708,"tvrage":38049}},"comment":{"id":229,"comment":"Is this the OC?","spoiler":false,"review":false,"parent_id":0,"created_at":"2015-04-21T15:42:31.000Z","replies":1,"likes":0,"user_rating":7,"user":{"username":"justin","private":false,"name":"Justin N.","vip":true,"vip_ep":false}}}, - {"type":"list","list":{"name":"Star Wars","description":"The complete Star Wars saga!","privacy":"public","display_numbers":false,"allow_comments":true,"updated_at":"2015-04-22T22:01:39.000Z","item_count":8,"comment_count":0,"likes":0,"ids":{"trakt":51,"slug":"star-wars"}},"comment":{"id":268,"comment":"May the 4th be with you!","spoiler":false,"review":false,"parent_id":0,"created_at":"2014-12-08T17:34:51.000Z","replies":0,"likes":0,"user_rating":null,"user":{"username":"justin","private":false,"name":"Justin N.","vip":true,"vip_ep":false}}} + {"type":"list","list":{"name":"Star Wars","description":"The complete Star Wars saga!","privacy":"public","type":"personal","display_numbers":false,"allow_comments":true,"updated_at":"2015-04-22T22:01:39.000Z","item_count":8,"comment_count":0,"likes":0,"ids":{"trakt":51,"slug":"star-wars"}},"comment":{"id":268,"comment":"May the 4th be with you!","spoiler":false,"review":false,"parent_id":0,"created_at":"2014-12-08T17:34:51.000Z","replies":0,"likes":0,"user_rating":null,"user":{"username":"justin","private":false,"name":"Justin N.","vip":true,"vip_ep":false}}} ] }, "users/sean/lists": { @@ -197,6 +198,7 @@ "name":"Star Wars in machete order", "description":"Some descriptive text", "privacy":"public", + "type":"personal", "display_numbers":true, "allow_comments":true, "sort_by": "rank", @@ -220,6 +222,7 @@ "name":"Vampires FTW", "description":"These suck, but in a good way!", "privacy":"public", + "type":"personal", "display_numbers":false, "allow_comments":true, "sort_by": "rank", @@ -244,6 +247,7 @@ "name":"Star Wars in machete order", "description":"Some descriptive text", "privacy":"public", + "type":"personal", "display_numbers":true, "allow_comments":true, "sort_by": "rank", @@ -261,6 +265,7 @@ "name":"Star Wars in machete order", "description":"Some descriptive text", "privacy":"public", + "type":"personal", "display_numbers":true, "allow_comments":true, "sort_by": "rank", @@ -277,6 +282,7 @@ "name":"Star Wars in NEW machete order", "description":"Some descriptive text", "privacy":"private", + "type":"personal", "display_numbers":true, "allow_comments":false, "sort_by": "rank", diff --git a/tests/test_episodes.py b/tests/test_episodes.py index c3b82582..8a41f84f 100644 --- a/tests/test_episodes.py +++ b/tests/test_episodes.py @@ -74,13 +74,13 @@ def test_oneliners(): e1.remove_from_collection, e1.remove_from_watchlist] for fn in functions: r = fn() - assert r is None + assert r is not None def test_episode_comment(): e1 = TVEpisode('Game of Thrones', season=1, number=1) r = e1.comment('Test Comment') - assert r is None + assert r is not None def test_episode_scrobble(): diff --git a/tests/test_movies.py b/tests/test_movies.py index f6156df7..da38e5b0 100644 --- a/tests/test_movies.py +++ b/tests/test_movies.py @@ -148,21 +148,27 @@ def test_movie_search(): assert all(isinstance(m, Movie) for m in results) +def test_dismiss(): + tron = Movie('Tron Legacy 2010') + r = tron.dismiss() + assert r is None + + def test_utilities(): tron = Movie('Tron Legacy 2010') functions = [tron.add_to_library, tron.add_to_collection, - tron.add_to_watchlist, tron.dismiss, tron.mark_as_unseen, + tron.add_to_watchlist, tron.mark_as_unseen, tron.remove_from_library, tron.remove_from_collection, tron.remove_from_watchlist, tron.mark_as_seen] for fn in functions: r = fn() - assert r is None + assert r is not None def test_movie_comment(): tron = Movie('Tron Legacy 2010') r = tron.comment('Some comment data') - assert r is None + assert r is not None def test_rate_movie(): diff --git a/tests/test_seasons.py b/tests/test_seasons.py index f061402c..23a6bb22 100644 --- a/tests/test_seasons.py +++ b/tests/test_seasons.py @@ -8,6 +8,10 @@ def test_get_seasons(): got = TVShow('Game of Thrones') assert all([isinstance(s, TVSeason) for s in got.seasons]) + season = got.seasons[1] + assert season.season == 1 + assert len(season.episodes) == 10 + assert all([isinstance(episode, TVEpisode) for episode in season.episodes]) def test_get_seasons_with_year(): @@ -49,7 +53,7 @@ def test_oneliners(): s1.remove_from_library, s1.remove_from_collection] for fn in functions: r = fn() - assert r is None + assert r is not None def test_season_to_json(): diff --git a/tests/test_shows.py b/tests/test_shows.py index 8f54819a..2ec24078 100644 --- a/tests/test_shows.py +++ b/tests/test_shows.py @@ -129,6 +129,13 @@ def test_show_comment(): assert got.comment('Test Comment Data').get('comment') +def test_collection_progress(): + show = TVShow('Game of Thrones') + assert isinstance(show.progress, dict) + assert isinstance(show.collection_progress(), dict) + assert isinstance(show.watched_progress(), dict) + + def test_rate_show(): got = TVShow('Game of Thrones') assert got.rate(10)['added'] == {'episodes': 2, 'movies': 1, 'seasons': 1, 'shows': 1} diff --git a/tests/test_sync.py b/tests/test_sync.py index e13bc3da..de335be5 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -10,7 +10,7 @@ -class FakeMedia(object): +class FakeMedia: """Mock media type object to use with mock sync requests""" media_type = 'fake' diff --git a/tests/test_users.py b/tests/test_users.py index 3b5cab23..7539aa54 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -128,3 +128,16 @@ def test_watched(): def test_stats(): sean = User('sean') assert isinstance(sean.get_stats(), dict) + + +def test_liked_lists(): + sean = User('sean') + + lists = sean.get_liked_lists() + assert lists is None + + lists = sean.get_liked_lists('lists') + assert isinstance(lists, list) + + lists = sean.get_liked_lists('comments') + assert isinstance(lists, list) diff --git a/tests/test_utils.py b/tests/test_utils.py index 9b6b5555..9c2526a0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -12,6 +12,8 @@ def test_slugify(): (' LOOK AT MY WHITESPACE ', 'look-at-my-whitespace'), ("Marvel's Agents of S.H.I.E.L.D.", 'marvel-s-agents-of-s-h-i-e-l-d'), ('Naruto Shippūden', 'naruto-shippuden'), + ('Re:ZERO -Starting Life in Another World-', 're-zero-starting-life-in-another-world'), + ('So I’m a Spider, So What?', 'so-i-m-a-spider-so-what'), ] for inp, expected in test_data: diff --git a/trakt/__init__.py b/trakt/__init__.py index 52a42816..1004aed6 100644 --- a/trakt/__init__.py +++ b/trakt/__init__.py @@ -5,6 +5,6 @@ except ImportError: pass -version_info = (3, 4, 0) -__author__ = 'Jon Nappi' -__version__ = '.'.join([str(i) for i in version_info]) +from .__version__ import __version__ + +__author__ = 'Jon Nappi, Elan Ruusamäe' diff --git a/trakt/__version__.py b/trakt/__version__.py new file mode 100644 index 00000000..4f7facb8 --- /dev/null +++ b/trakt/__version__.py @@ -0,0 +1 @@ +__version__ = "Unknown" diff --git a/trakt/calendar.py b/trakt/calendar.py index 5fdae2d0..b330c72a 100644 --- a/trakt/calendar.py +++ b/trakt/calendar.py @@ -12,7 +12,7 @@ 'MySeasonCalendar', 'MovieCalendar', 'MyMovieCalendar'] -class Calendar(object): +class Calendar: """Base :class:`Calendar` type serves as a foundation for other Calendar types """ @@ -26,7 +26,7 @@ def __init__(self, date=None, days=7, extended=None): :param days: Number of days for this :class:`Calendar`. Defaults to 7 days """ - super(Calendar, self).__init__() + super().__init__() self.date = date or now() self.days = days self._calendar = [] @@ -81,7 +81,8 @@ def _build(self, data): 'show_data': TVShow(**show_data) } self._calendar.append( - TVEpisode(show_data['title'], season, ep_num, **e_data) + TVEpisode(show_data['title'], season, ep_num, + show_id=show_data['trakt'], **e_data) ) self._calendar = sorted(self._calendar, key=lambda x: x.airs_at) diff --git a/trakt/core.py b/trakt/core.py index dd3f8931..faae2f72 100644 --- a/trakt/core.py +++ b/trakt/core.py @@ -5,6 +5,7 @@ import json import logging import os +from json import JSONDecodeError from urllib.parse import urljoin import requests @@ -15,6 +16,7 @@ from requests_oauthlib import OAuth2Session from datetime import datetime, timedelta, timezone from trakt import errors +from trakt.errors import BadResponseException __author__ = 'Jon Nappi' __all__ = ['Airs', 'Alias', 'Comment', 'Genre', 'get', 'delete', 'post', 'put', @@ -43,7 +45,7 @@ CONFIG_PATH = os.path.join(os.path.expanduser('~'), '.pytrakt.json') #: Your personal Trakt.tv OAUTH Bearer Token -OAUTH_TOKEN = api_key = None +OAUTH_TOKEN = None # OAuth token validity checked OAUTH_TOKEN_VALID = None @@ -441,7 +443,7 @@ def load_config(): APPLICATION_ID = config_data.get('APPLICATION_ID', None) -class Core(object): +class Core: """This class contains all of the functionality required for interfacing with the Trakt.tv API """ @@ -476,9 +478,6 @@ def _bootstrap(self): if (not OAUTH_TOKEN_VALID and OAUTH_EXPIRES_AT is not None and OAUTH_REFRESH is not None): _validate_token(self) - # For backwards compatibility with trakt<=2.3.0 - if api_key is not None and OAUTH_TOKEN is None: - OAUTH_TOKEN = api_key @staticmethod def _get_first(f, *args, **kwargs): @@ -516,7 +515,6 @@ def _handle_request(self, method, url, data=None): self.logger.debug('%s: %s', method, url) HEADERS['trakt-api-key'] = CLIENT_ID HEADERS['Authorization'] = 'Bearer {0}'.format(OAUTH_TOKEN) - self.logger.debug('headers: %s', str(HEADERS)) self.logger.debug('method, url :: %s, %s', method, url) if method == 'get': # GETs need to pass data as params, not body response = session.request(method, url, headers=HEADERS, @@ -529,7 +527,12 @@ def _handle_request(self, method, url, data=None): raise self.error_map[response.status_code](response) elif response.status_code == 204: # HTTP no content return None - json_data = json.loads(response.content.decode('UTF-8', 'ignore')) + + try: + json_data = json.loads(response.content.decode('UTF-8', 'ignore')) + except JSONDecodeError as e: + raise BadResponseException(response, f"Unable to parse JSON: {e}") + return json_data def get(self, f): diff --git a/trakt/errors.py b/trakt/errors.py index 8a54cd48..9996500b 100644 --- a/trakt/errors.py +++ b/trakt/errors.py @@ -6,7 +6,13 @@ __author__ = 'Jon Nappi' __all__ = [ + # Base Exception 'TraktException', + + # Errors for use by PyTrakt + 'BadResponseException', + + # Exceptions by HTTP status code 'BadRequestException', 'OAuthException', 'ForbiddenException', @@ -17,6 +23,7 @@ 'LockedUserAccountException', 'RateLimitException', 'TraktInternalException', + 'TraktBadGateway', 'TraktUnavailable', ] @@ -32,6 +39,16 @@ def __str__(self): return self.message +class BadResponseException(TraktException): + """TraktException type to be raised when json could not be decoded""" + http_code = -1 + message = "Bad Response - Response could not be parsed" + + def __init__(self, response=None, details=None): + super().__init__(response) + self.details = details + + class BadRequestException(TraktException): """TraktException type to be raised when a 400 return code is received""" http_code = 400 @@ -92,6 +109,12 @@ class TraktInternalException(TraktException): message = 'Internal Server Error' +class TraktBadGateway(TraktException): + """TraktException type to be raised when a 502 error is raised""" + http_code = 502 + message = 'Trakt Unavailable - Bad Gateway' + + class TraktUnavailable(TraktException): """TraktException type to be raised when a 503 error is raised""" http_code = 503 diff --git a/trakt/movies.py b/trakt/movies.py index fd5f15cf..e6be7c73 100644 --- a/trakt/movies.py +++ b/trakt/movies.py @@ -81,10 +81,10 @@ def updated_movies(timestamp=None): 'note', 'release_type']) -class Movie(object): +class Movie: """A Class representing a Movie object""" def __init__(self, title, year=None, slug=None, **kwargs): - super(Movie, self).__init__() + super().__init__() self.media_type = 'movies' self.title = title self.year = int(year) if year is not None else year @@ -94,7 +94,7 @@ def __init__(self, title, year=None, slug=None, **kwargs): self.slug = slug or slugify(self.title) self.released = self.tmdb_id = self.imdb_id = self.duration = None - self.trakt_id = self.tagline = self.overview = self.runtime = None + self.trakt = self.trakt_id = self.tagline = self.overview = self.runtime = None self.updated_at = self.trailer = self.homepage = self.rating = None self.votes = self.language = self.available_translations = None self.genres = self.certification = None @@ -106,8 +106,8 @@ def __init__(self, title, year=None, slug=None, **kwargs): else: self._get() - @classmethod - def search(cls, title, year=None): + @staticmethod + def search(title, year=None): """Perform a search for a movie with a title matching *title* :param title: The title to search for @@ -258,16 +258,16 @@ def watching_now(self): def add_to_library(self): """Add this :class:`Movie` to your library.""" - add_to_collection(self) + return add_to_collection(self) add_to_collection = add_to_library def add_to_watchlist(self): """Add this :class:`Movie` to your watchlist""" - add_to_watchlist(self) + return add_to_watchlist(self) def comment(self, comment_body, spoiler=False, review=False): """Add a comment (shout or review) to this :class:`Move` on trakt.""" - comment(self, comment_body, spoiler, review) + return comment(self, comment_body, spoiler, review) def dismiss(self): """Dismiss this movie from showing up in Movie Recommendations""" @@ -305,28 +305,28 @@ def get_translations(self, country_code='us'): def mark_as_seen(self, watched_at=None): """Add this :class:`Movie`, watched outside of trakt, to your library. """ - add_to_history(self, watched_at) + return add_to_history(self, watched_at) def mark_as_unseen(self): """Remove this :class:`Movie`, watched outside of trakt, from your library. """ - remove_from_history(self) + return remove_from_history(self) def rate(self, rating): """Rate this :class:`Movie` on trakt. Depending on the current users settings, this may also send out social updates to facebook, twitter, tumblr, and path. """ - rate(self, rating) + return rate(self, rating) def remove_from_library(self): """Remove this :class:`Movie` from your library.""" - remove_from_collection(self) + return remove_from_collection(self) remove_from_collection = remove_from_library def remove_from_watchlist(self): - remove_from_watchlist(self) + return remove_from_watchlist(self) def scrobble(self, progress, app_version, app_date): """Notify trakt that the current user has finished watching a movie. @@ -362,7 +362,7 @@ def checkin(self, app_version, app_date, message="", sharing=None, """ if delete: delete_checkin() - checkin_media(self, app_version, app_date, message, sharing, venue_id, + return checkin_media(self, app_version, app_date, message, sharing, venue_id, venue_name) def to_json_singular(self): diff --git a/trakt/people.py b/trakt/people.py index 074dfdb5..ace75e4d 100644 --- a/trakt/people.py +++ b/trakt/people.py @@ -9,10 +9,10 @@ 'TVCredits'] -class Person(object): +class Person: """A Class representing a trakt.tv Person such as an Actor or Director""" def __init__(self, name, slug=None, **kwargs): - super(Person, self).__init__() + super().__init__() self.name = name self.biography = self.birthplace = self.tmdb_id = self.birthday = None self.job = self.character = self._images = self._movie_credits = None @@ -24,8 +24,8 @@ def __init__(self, name, slug=None, **kwargs): else: self._get() - @classmethod - def search(cls, name, year=None): + @staticmethod + def search(name, year=None): """Perform a search for an episode with a title matching *title* :param name: The name of the person to search for @@ -112,7 +112,7 @@ def __str__(self): __repr__ = __str__ -class ActingCredit(object): +class ActingCredit: """An individual credit for a :class:`Person` who played a character in a Movie or TV Show """ @@ -130,7 +130,7 @@ def __str__(self): __repr__ = __str__ -class CrewCredit(object): +class CrewCredit: """An individual crew credit for a :class:`Person` who had an off-screen job on a Movie or a TV Show """ @@ -148,7 +148,7 @@ def __str__(self): __repr__ = __str__ -class Credits(object): +class Credits: """A base type representing a :class:`Person`'s credits for Movies or TV Shows """ diff --git a/trakt/sync.py b/trakt/sync.py index c4bf7cfe..c184166c 100644 --- a/trakt/sync.py +++ b/trakt/sync.py @@ -2,6 +2,8 @@ """This module contains Trakt.tv sync endpoint support functions""" from datetime import datetime, timezone +from deprecated import deprecated + from trakt.core import get, post, delete from trakt.utils import slugify, extract_ids, timestamp @@ -221,6 +223,7 @@ def get_search_results(query, search_type=None, slugify_query=False): from trakt.tv import TVEpisode show = media_item.pop('show') result.media = TVEpisode(show.get('title', None), + show_id=show['ids'].get('trakt'), **media_item.pop('episode')) elif media_item['type'] == 'person': from trakt.people import Person @@ -286,7 +289,9 @@ def search_by_id(query, id_type='imdb', media_type=None, slugify_query=False): from trakt.tv import TVEpisode show = d.pop('show') extract_ids(d['episode']) - results.append(TVEpisode(show.get('title', None), **d['episode'])) + results.append(TVEpisode(show.get('title', None), + show_id=show['ids'].get('trakt'), + **d.pop('episode'))) elif 'movie' in d: from trakt.movies import Movie results.append(Movie(**d.pop('movie'))) @@ -302,13 +307,18 @@ def search_by_id(query, id_type='imdb', media_type=None, slugify_query=False): @get def get_watchlist(list_type=None, sort=None): """ - Get a watchlist. - + Returns all items in a user's watchlist filtered by type. optionally with a filter for a specific item type. + + The watchlist should not be used as a list + of what the user is actively watching. + :param list_type: Optional Filter by a specific type. Possible values: movies, shows, seasons or episodes. :param sort: Optional sort. Only if the type is also sent. Possible values: rank, added, released or title. + + https://trakt.docs.apiary.io/#reference/sync/get-watchlist/get-watchlist """ valid_type = ('movies', 'shows', 'seasons', 'episodes') valid_sort = ('rank', 'added', 'released', 'title') @@ -333,7 +343,9 @@ def get_watchlist(list_type=None, sort=None): from trakt.tv import TVEpisode show = d.pop('show') extract_ids(d['episode']) - results.append(TVEpisode(show.get('title', None), **d['episode'])) + results.append(TVEpisode(show.get('title', None), + show_id=show.get('trakt', None), + **d['episode'])) elif 'movie' in d: from trakt.movies import Movie results.append(Movie(**d.pop('movie'))) @@ -344,6 +356,8 @@ def get_watchlist(list_type=None, sort=None): yield results +@deprecated("This method returns watchlist, not watched list. " + "This will be fixed in PyTrakt 4.x to return watched list") @get def get_watched(list_type=None, extended=None): """Return all movies or shows a user has watched sorted by most plays. @@ -357,7 +371,7 @@ def get_watched(list_type=None, extended=None): if list_type and list_type not in valid_type: raise ValueError('list_type must be one of {}'.format(valid_type)) - uri = 'sync/watchlist' + uri = 'sync/watched' if list_type: uri += '/{}'.format(list_type) @@ -394,7 +408,7 @@ def get_collection(list_type=None, extended=None): if list_type and list_type not in valid_type: raise ValueError('list_type must be one of {}'.format(valid_type)) - uri = 'sync/watchlist' + uri = 'sync/collection' if list_type: uri += '/{}'.format(list_type) @@ -431,7 +445,7 @@ def delete_checkin(): yield "checkin" -class Scrobbler(object): +class Scrobbler: """Scrobbling is a seemless and automated way to track what you're watching in a media center. This class allows the media center to easily send events that correspond to starting, pausing, stopping or finishing @@ -448,23 +462,29 @@ def __init__(self, media, progress, app_version, app_date): :param app_version: The media center application version :param app_date: The date that *app_version* was released """ - super(Scrobbler, self).__init__() + super().__init__() self.progress, self.version = progress, app_version self.media, self.date = media, app_date if self.progress > 0: self.start() - def start(self): + def start(self, progress=None): """Start scrobbling this :class:`Scrobbler`'s *media* object""" - self._post('scrobble/start') + if progress is not None: + self.progress = progress + return self._post('scrobble/start') - def pause(self): + def pause(self, progress=None): """Pause the scrobbling of this :class:`Scrobbler`'s *media* object""" - self._post('scrobble/pause') + if progress is not None: + self.progress = progress + return self._post('scrobble/pause') - def stop(self): + def stop(self, progress=None): """Stop the scrobbling of this :class:`Scrobbler`'s *media* object""" - self._post('scrobble/stop') + if progress is not None: + self.progress = progress + return self._post('scrobble/stop') def finish(self): """Complete the scrobbling this :class:`Scrobbler`'s *media* object""" @@ -477,7 +497,7 @@ def update(self, progress): object """ self.progress = progress - self.start() + return self.start() @post def _post(self, uri): @@ -488,7 +508,8 @@ def _post(self, uri): payload = dict(progress=self.progress, app_version=self.version, date=self.date) payload.update(self.media.to_json_singular()) - yield uri, payload + response = yield uri, payload + yield response def __enter__(self): """Context manager support for `with Scrobbler` syntax. Begins @@ -504,7 +525,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.finish() -class SearchResult(object): +class SearchResult: """A SearchResult is an individual result item from the trakt.tv search API. It wraps a single media entity whose type is indicated by the type field. diff --git a/trakt/tv.py b/trakt/tv.py index 9c60d9f1..8273a9a8 100644 --- a/trakt/tv.py +++ b/trakt/tv.py @@ -2,6 +2,8 @@ """Interfaces to all of the TV objects offered by the Trakt.tv API""" from collections import namedtuple from datetime import datetime, timedelta +from urllib.parse import urlencode + from trakt.core import Airs, Alias, Comment, Genre, delete, get from trakt.errors import NotFoundException from trakt.sync import (Scrobbler, rate, comment, add_to_collection, @@ -195,11 +197,11 @@ def anticipated_shows(page=1, limit=10, extended=None): yield [TVShow(**show['show']) for show in data] -class TVShow(object): +class TVShow: """A Class representing a TV Show object.""" def __init__(self, title='', slug=None, **kwargs): - super(TVShow, self).__init__() + super().__init__() self.media_type = 'shows' self.top_watchers = self.top_episodes = self.year = self.tvdb = None self.imdb = self.genres = self.certification = self.network = None @@ -225,8 +227,8 @@ def slug(self): return slugify(self.title + ' ' + str(self.year)) - @classmethod - def search(cls, title, year=None): + @staticmethod + def search(title, year=None): """Perform a search for the specified *title*. :param title: The title to search for @@ -295,6 +297,24 @@ def comments(self): self._comments.append(Comment(user=user, **com)) yield self._comments + def _progress(self, progress_type, + specials=False, count_specials=False, hidden=False): + uri = f'{self.ext}/progress/{progress_type}' + params = {} + if specials: + params['specials'] = 'true' + if count_specials: + params['count_specials'] = 'true' + if hidden: + params['hidden'] = 'true' + + if params: + uri += '?' + urlencode(params) + + data = yield uri + + yield data + @property @get def progress(self): @@ -305,7 +325,38 @@ def progress(self): The next_episode will be the next episode the user should collect, if there are no upcoming episodes it will be set to null. """ - yield (self.ext + '/progress/collection') + return self._progress('collection') + + @get + def collection_progress(self, **kwargs): + """ + collection progress for a show including details on all aired + seasons and episodes. + + The next_episode will be the next episode the user should collect, + if there are no upcoming episodes it will be set to null. + + specials: include specials as season 0. Default: false. + count_specials: count specials in the overall stats. Default: false. + hidden: include any hidden seasons. Default: false. + + https://trakt.docs.apiary.io/#reference/shows/collection-progress/get-show-collection-progress + """ + return self._progress('collection', **kwargs) + + @get + def watched_progress(self, **kwargs): + """ + watched progress for a show including details on all aired seasons + and episodes. + + specials: include specials as season 0. Default: false. + count_specials: count specials in the overall stats. Default: false. + hidden: include any hidden seasons. Default: false. + + https://trakt.docs.apiary.io/#reference/shows/watched-progress/get-show-collection-progress + """ + return self._progress('watched', **kwargs) @property def crew(self): @@ -377,15 +428,25 @@ def related(self): @get def seasons(self): """A list of :class:`TVSeason` objects representing all of this show's - seasons + seasons which each contain :class:`TVEpisode` elements """ if self._seasons is None: - data = yield self.ext + '/seasons?extended=full' + data = yield self.ext + '/seasons?extended=episodes' self._seasons = [] for season in data: extract_ids(season) - self._seasons.append(TVSeason(self.title, - season['number'], **season)) + + # Prepare episodes + episodes = [] + for ep in season.pop('episodes', []): + episode = TVEpisode(show=self.title, + show_id=self.trakt, **ep) + episodes.append(episode) + season['episodes'] = episodes + + number = season.pop('number') + season = TVSeason(self.title, number, self.slug, **season) + self._seasons.append(season) yield self._seasons @property @@ -396,7 +457,8 @@ def last_episode(self): """ if self._last_episode is None: data = yield self.ext + '/last_episode?extended=full' - self._last_episode = data and TVEpisode(show=self.title, **data) + self._last_episode = data and TVEpisode(show=self.title, + show_id=self.trakt, **data) yield self._last_episode @property @@ -407,7 +469,8 @@ def next_episode(self): """ if self._next_episode is None: data = yield self.ext + '/next_episode?extended=full' - self._next_episode = data and TVEpisode(show=self.title, **data) + self._next_episode = data and TVEpisode(show=self.title, + show_id=self.trakt, **data) yield self._next_episode @property @@ -499,11 +562,11 @@ def __str__(self): __repr__ = __str__ -class TVSeason(object): +class TVSeason: """Container for TV Seasons""" def __init__(self, show, season=1, slug=None, **kwargs): - super(TVSeason, self).__init__() + super().__init__() self.show = show self.season = season self.slug = slug or slugify(show) @@ -610,13 +673,13 @@ def watching_now(self): def add_to_library(self): """Add this :class:`TVSeason` to your library.""" - add_to_collection(self) + return add_to_collection(self) add_to_collection = add_to_library def remove_from_library(self): """Remove this :class:`TVSeason` from your library.""" - remove_from_collection(self) + return remove_from_collection(self) remove_from_collection = remove_from_library @@ -639,11 +702,11 @@ def __len__(self): __repr__ = __str__ -class TVEpisode(object): +class TVEpisode: """Container for TV Episodes""" def __init__(self, show, season, number=-1, **kwargs): - super(TVEpisode, self).__init__() + super().__init__() self.media_type = 'episodes' self.show = show self.season = season @@ -694,8 +757,11 @@ def comments(self): @property def ext(self): + show_id = getattr(self, "show_id", None) + if not show_id: + show_id = slugify(self.show) return 'shows/{id}/seasons/{season}/episodes/{episode}'.format( - id=slugify(self.show), season=self.season, episode=self.number + id=show_id, season=self.season, episode=self.number ) @property @@ -707,8 +773,8 @@ def images_ext(self): """Uri to retrieve additional image information""" return self.ext + '?extended=images' - @classmethod - def search(cls, title, year=None): + @staticmethod + def search(title, year=None): """Perform a search for an episode with a title matching *title* :param title: The title to search for @@ -798,40 +864,40 @@ def rate(self, rating): settings, this may also send out social updates to facebook, twitter, tumblr, and path. """ - rate(self, rating) + return rate(self, rating) def add_to_library(self): """Add this :class:`TVEpisode` to your Trakt.tv library""" - add_to_collection(self) + return add_to_collection(self) add_to_collection = add_to_library def add_to_watchlist(self): """Add this :class:`TVEpisode` to your watchlist""" - add_to_watchlist(self) + return add_to_watchlist(self) def mark_as_seen(self, watched_at=None): """Mark this episode as seen""" - add_to_history(self, watched_at) + return add_to_history(self, watched_at) def mark_as_unseen(self): """Remove this :class:`TVEpisode` from your list of watched episodes""" - remove_from_history(self) + return remove_from_history(self) def remove_from_library(self): """Remove this :class:`TVEpisode` from your library""" - remove_from_collection(self) + return remove_from_collection(self) remove_from_collection = remove_from_library def remove_from_watchlist(self): """Remove this :class:`TVEpisode` from your watchlist""" - remove_from_watchlist(self) + return remove_from_watchlist(self) def comment(self, comment_body, spoiler=False, review=False): """Add a comment (shout or review) to this :class:`TVEpisode` on trakt. """ - comment(self, comment_body, spoiler, review) + return comment(self, comment_body, spoiler, review) def scrobble(self, progress, app_version, app_date): """Scrobble this :class:`TVEpisode` via the TraktTV Api @@ -864,7 +930,7 @@ def checkin(self, app_version, app_date, message="", sharing=None, """ if delete: delete_checkin() - checkin_media(self, app_version, app_date, message, sharing, venue_id, + return checkin_media(self, app_version, app_date, message, sharing, venue_id, venue_name) def to_json_singular(self): diff --git a/trakt/users.py b/trakt/users.py index 523b83d3..60a4b862 100644 --- a/trakt/users.py +++ b/trakt/users.py @@ -61,20 +61,21 @@ def unfollow(user_name): class UserList(namedtuple('UserList', ['name', 'description', 'privacy', - 'display_numbers', 'allow_comments', - 'sort_by', 'sort_how', 'created_at', + 'type', 'display_numbers', + 'allow_comments', 'sort_by', + 'sort_how', 'created_at', 'updated_at', 'item_count', 'comment_count', 'likes', 'trakt', 'slug', 'user', 'creator'])): """A list created by a Trakt.tv :class:`User`""" def __init__(self, *args, **kwargs): - super(UserList, self).__init__() + super().__init__() self._items = list() - def __iter__(self, *args, **kwargs): + def __iter__(self): """Iterate over the items in this user list""" - return self._items.__iter__(*args, **kwargs) + return self._items.__iter__() @classmethod @post @@ -96,7 +97,7 @@ def create(cls, name, creator, description=None, privacy='private', args['description'] = description data = yield 'users/{user}/lists'.format(user=slugify(creator)), args extract_ids(data) - yield UserList(creator=creator, user=creator, **data) + yield cls(creator=creator, user=creator, **data) @classmethod @get @@ -108,7 +109,7 @@ def _get(cls, title, creator): data = yield 'users/{user}/lists/{id}'.format(user=slugify(creator), id=slugify(title)) extract_ids(data) - ulist = UserList(creator=creator, **data) + ulist = cls(creator=creator, **data) ulist.get_items() yield ulist @@ -146,7 +147,8 @@ def get_items(self): show_data = item.pop('show') extract_ids(show_data) episode = TVEpisode(show_data['title'], item_data['season'], - item_data['number']) + item_data['number'], + show_id=show_data['trakt']) self._items.append(episode) elif item_type == 'person': self._items.append(Person(item_data['name'], @@ -200,10 +202,10 @@ def unlike(self): yield uri.format(username=slugify(self.creator), id=self.trakt) -class User(object): +class User: """A Trakt.tv User""" def __init__(self, username, **kwargs): - super(User, self).__init__() + super().__init__() self.username = username self._calendar = self._last_activity = self._watching = None self._movies = self._movie_collection = self._movies_watched = None @@ -372,7 +374,8 @@ def show_collection(self): s = show.pop('show') extract_ids(s) sh = TVShow(**s) - sh._seasons = [TVSeason(show=sh.title, **sea) + sh._seasons = [TVSeason(show=sh.title, + season=sea['number'], **sea) for sea in show.pop('seasons')] self._show_collection.append(sh) yield self._show_collection @@ -380,7 +383,7 @@ def show_collection(self): @property @get def watched_movies(self): - """Watched profess for all :class:`Movie`'s in this :class:`User`'s + """Watched progress for all :class:`Movie`'s in this :class:`User`'s collection. """ if self._watched_movies is None: @@ -398,7 +401,7 @@ def watched_movies(self): @property @get def watched_shows(self): - """Watched profess for all :class:`TVShow`'s in this :class:`User`'s + """Watched progress for all :class:`TVShow`'s in this :class:`User`'s collection. """ if self._watched_shows is None: @@ -438,7 +441,8 @@ def watching(self): ep_data = data.pop('episode') extract_ids(ep_data) sh_data = data.pop('show') - ep_data.update(data, show=sh_data.get('title')) + ep_data.update(data, show=sh_data.get('title'), + show_id=sh_data.get('trakt')) yield TVEpisode(**ep_data) @staticmethod @@ -480,6 +484,27 @@ def get_stats(self): data = yield 'users/{user}/stats'.format(user=slugify(self.username)) yield data + @get + def get_liked_lists(self, list_type=None, limit=None): + """Get items a user likes. + + This will return an array of standard media objects. + You can optionally limit the type of results to return. + + list_type possible values are "comments", "lists". + + https://trakt.docs.apiary.io/#reference/users/likes/get-likes + """ + uri = 'users/likes' + if list_type is not None: + uri += f'/{list_type}' + + if limit is not None: + uri += f'?limit={limit}' + + data = yield uri + yield data + def follow(self): """Follow this :class:`User`""" follow(self.username) diff --git a/trakt/utils.py b/trakt/utils.py index fbf54dc5..f9785b4e 100644 --- a/trakt/utils.py +++ b/trakt/utils.py @@ -14,10 +14,16 @@ def slugify(value): Adapted from django.utils.text.slugify """ - nfkd_form = unicodedata.normalize('NFKD', value) - decoded = nfkd_form.encode('ascii', 'ignore').decode('utf-8') - value = re.sub(r'[^\w\s-]', ' ', decoded).strip().lower() - return re.sub(r'[-\s]+', '-', value) + value = unicodedata.normalize('NFKD', value) + # special case, "ascii" encode would just remove it + value = value.replace("’", '-') + value = value.encode('ascii', 'ignore').decode('utf-8') + value = value.lower() + value = re.sub(r'[^\w\s-]', ' ', value) + value = re.sub(r'[-\s]+', '-', value) + value = value.strip('-') + + return value def airs_date(airs_at):