Browse Source

Initial commit

Originally based off of mopidy-scrobbler code
Colin Powell 2 years ago
commit
bbd1f5b5b7
14 changed files with 729 additions and 0 deletions
  1. 9 0
      .gitignore
  2. 202 0
      LICENSE
  3. 14 0
      MANIFEST.in
  4. 52 0
      README.rst
  5. 28 0
      mopidy_webhooks/__init__.py
  6. 4 0
      mopidy_webhooks/ext.conf
  7. 95 0
      mopidy_webhooks/frontend.py
  8. 16 0
      pyproject.toml
  9. 83 0
      setup.cfg
  10. 3 0
      setup.py
  11. 0 0
      tests/__init__.py
  12. 35 0
      tests/test_extension.py
  13. 169 0
      tests/test_frontend.py
  14. 19 0
      tox.ini

+ 9 - 0
.gitignore

@@ -0,0 +1,9 @@
+*.pyc
+/.coverage
+/.mypy_cache/
+/.pytest_cache/
+/.tox/
+/*.egg-info
+/build/
+/dist/
+/MANIFEST

+ 202 - 0
LICENSE

@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 14 - 0
MANIFEST.in

@@ -0,0 +1,14 @@
+include *.py
+include *.rst
+include .mailmap
+include LICENSE
+include MANIFEST.in
+include pyproject.toml
+include tox.ini
+
+recursive-include .github *
+
+include mopidy_*/ext.conf
+
+recursive-include tests *.py
+recursive-include tests/data *

+ 52 - 0
README.rst

@@ -0,0 +1,52 @@
+****************
+Mopidy-Webhooks
+****************
+
+.. image:: https://img.shields.io/pypi/v/Mopidy-Webhooks
+    :target: https://pypi.org/project/Mopidy-Webhooks/
+    :alt: Latest PyPI version
+
+`Mopidy <https://www.mopidy.com/>`_ extension for sending mopidy play status to
+arbitrary URL endpoints
+
+Installation
+============
+
+Install by running::
+
+    sudo python3 -m pip install Mopidy-Webhooks
+
+
+Configuration
+=============
+
+To enable this extension, make sure to add the following variables to your
+Mopidy configuration file. Note that multiple URLs can be sent to by separating
+them with a comma. Tokens match the listing of urls::
+
+    [webhooks]
+    enabled = true
+    urls = https://example.com/api/receiver/,http://127.0.0.1:8000/webhook/
+    tokens = ,2349080989234089
+
+The following configuration values are available:
+
+- ``webhooks/enabled``: If the webhooks extension should be enabled or not.
+  Defaults to disabled.
+- ``webhooks/urls``: Comma-separated list of endpoints to send play data to
+- ``webhooks/tokens``: Comma-separated list of tokens to send in the
+  Authorization header for each URL
+
+
+Project resources
+=================
+
+- `Source code <https://github.com/powellc/mopidy-webhooks>`_
+- `Issue tracker <https://github.com/powellc/mopidy-webhooks/issues>`_
+- `Changelog <https://github.com/powellc/mopidy-webhooks/releases>`_
+
+
+Credits
+=======
+
+- Author: `Colin Powell <https://github.com/powellc>`__

+ 28 - 0
mopidy_webhooks/__init__.py

@@ -0,0 +1,28 @@
+import pathlib
+
+import pkg_resources
+
+from mopidy import config, ext
+
+__version__ = pkg_resources.get_distribution("Mopidy-Webhooks").version
+
+
+class Extension(ext.Extension):
+
+    dist_name = "Mopidy-Webhooks"
+    ext_name = "webhooks"
+    version = __version__
+
+    def get_default_config(self):
+        return config.read(pathlib.Path(__file__).parent / "ext.conf")
+
+    def get_config_schema(self):
+        schema = super().get_config_schema()
+        schema["url"] = config.String()
+        schema["token"] = config.Secret()
+        return schema
+
+    def setup(self, registry):
+        from .frontend import WebhooksFrontend
+
+        registry.add("frontend", WebhooksFrontend)

+ 4 - 0
mopidy_webhooks/ext.conf

@@ -0,0 +1,4 @@
+[webhooks]
+enabled = true
+urls = ""
+tokens = ""

+ 95 - 0
mopidy_webhooks/frontend.py

@@ -0,0 +1,95 @@
+import logging
+import time
+import json
+
+import pykka
+import requests
+from mopidy.core import CoreListener
+
+logger = logging.getLogger(__name__)
+
+
+class WebhooksFrontend(pykka.ThreadingActor, CoreListener):
+    def __init__(self, config, core):
+        super().__init__()
+        self.config = config
+        self.webhook_urls = []
+        self.last_start_time = None
+
+    def on_start(self):
+        logger.info("Parsing webhook URLs and tokens")
+        self.webhook_urls = self.config["webhooks"]["urls"].split(",")
+        self.webhook_tokens = self.config["webhooks"]["tokens"].split(",")
+
+    def _build_post_data(self, track) -> dict:
+        primary_artist = track.artists[0]
+        duration = track.length and track.length // 1000 or 0
+        return {
+            "name": track.name,
+            "artist": primary_artist.name,
+            "album": track.album,
+            "track_number": track.track_no,
+            "run_time_ticks": track.length,
+            "run_time": str(duration),
+            "musicbrainz_track_id": track.musicbrainz_id,
+            "musicbrainz_album_id": track.album.musicbrainz_id
+            if track.album
+            else None,
+            "musicbrainz_artist_id": track.artist.musicbrainz_id
+            if primary_artist
+            else None,
+        }
+
+    def _post_update_to_webhooks(self, post_data: dict, status: str):
+        post_data["status"] = status
+
+        for index, webhook_url in enumerate(self.webhook_urls):
+            token = ""
+            headers = {}
+            try:
+                token = self.webhook_tokens[index]
+            except IndexError:
+                logger.info(f"No token found for Webhook URL: {webhook_url}")
+
+            if token:
+                headers["Authorization"] = "Token {token}"
+
+            response = requests.post(
+                webhook_url, json=json.dumps(post_data), headers=headers
+            )
+            logger.info(response)
+
+    def track_playback_started(self, tl_track):
+        track = tl_track.track
+        self.last_start_time = int(time.time())
+        logger.debug(f"Now playing track: {track.artists[0]} - {track.name}")
+        post_data = self._build_post_data(tl_track.track)
+
+        # Build post data to send to urls
+        if not self.webhook_urls:
+            logger.info("No webhook URLS are configured ")
+            return
+
+        self._post_update_to_webhooks(post_data, "started")
+
+    def track_playback_ended(self, tl_track, time_position):
+        track = tl_track.track
+        duration = track.length and track.length // 1000 or 0
+        time_position = time_position // 1000
+
+        logger.debug(f"Now playing track: {track.artists[0]} - {track.name}")
+        post_data = self._build_post_data(tl_track.track)
+
+        if time_position < duration // 2 and time_position < 240:
+            logger.debug(
+                "Track not played long enough to scrobble. (50% or 240s)"
+            )
+            return
+
+        if self.last_start_time is None:
+            self.last_start_time = int(time.time()) - duration
+        logger.debug(
+            f"Sending scroble to webhooks for track: {track.artists} - {track.name}"
+        )
+
+        self._post_update_to_webhooks(post_data, "stopped")

+ 16 - 0
pyproject.toml

@@ -0,0 +1,16 @@
+[build-system]
+requires = ["setuptools >= 30.3.0", "wheel"]
+
+[tool.black]
+target-version = ["py38", "py39"]
+line-length = 80
+
+
+[tool.isort]
+multi_line_output = 3
+include_trailing_comma = true
+force_grid_wrap = 0
+use_parentheses = true
+line_length = 88
+known_tests = "tests"
+sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER"

+ 83 - 0
setup.cfg

@@ -0,0 +1,83 @@
+[metadata]
+name = Mopidy-Webhooks
+version = 0.1.0
+url = https://github.com/powellc/mopidy-webhooks
+author = Colin Powell
+author_email = colin@unbl.ink
+license = Apache License, Version 2.0
+license_file = LICENSE
+description = Mopidy extension for sending playback data to webhook urls
+long_description = file: README.rst
+classifiers =
+    Environment :: No Input/Output (Daemon)
+    Intended Audience :: End Users/Desktop
+    License :: OSI Approved :: Apache Software License
+    Operating System :: OS Independent
+    Programming Language :: Python :: 3
+    Programming Language :: Python :: 3.7
+    Programming Language :: Python :: 3.8
+    Programming Language :: Python :: 3.9
+    Topic :: Multimedia :: Sound/Audio :: Players
+
+
+[options]
+zip_safe = False
+include_package_data = True
+packages = find:
+python_requires = >= 3.7
+install_requires =
+    Mopidy >= 3.0.0
+    Pykka >= 2.0.1
+    setuptools
+
+
+[options.extras_require]
+lint =
+    black
+    check-manifest
+    flake8
+    flake8-black
+    flake8-bugbear
+    flake8-import-order
+    isort[pyproject]
+test =
+    pytest
+    pytest-cov
+dev =
+    %(lint)s
+    %(test)s
+
+
+[options.packages.find]
+exclude =
+    tests
+    tests.*
+
+
+[options.entry_points]
+mopidy.ext =
+    webhooks = mopidy_webhooks:Extension
+
+
+[flake8]
+application-import-names = mopidy_webhooks, tests
+max-line-length = 80
+exclude = .git, .tox, build
+select =
+    # Regular flake8 rules
+    C, E, F, W
+    # flake8-bugbear rules
+    B
+    # B950: line too long (soft speed limit)
+    B950
+    # pep8-naming rules
+    N
+ignore =
+    # E203: whitespace before ':' (not PEP8 compliant)
+    E203
+    # E501: line too long (replaced by B950)
+    E501
+    # W503: line break before binary operator (not PEP8 compliant)
+    W503
+    # B305: .next() is not a thing on Python 3 (used by playback controller)
+    B305

+ 3 - 0
setup.py

@@ -0,0 +1,3 @@
+from setuptools import setup
+
+setup()

+ 0 - 0
tests/__init__.py


+ 35 - 0
tests/test_extension.py

@@ -0,0 +1,35 @@
+from unittest import mock
+
+from mopidy_webhooks import Extension
+from mopidy_webhooks import frontend as frontend_lib
+
+
+def test_get_default_config():
+    ext = Extension()
+
+    config = ext.get_default_config()
+
+    assert "[webhooks]" in config
+    assert "enabled = true" in config
+    assert "username =" in config
+    assert "password =" in config
+
+
+def test_get_config_schema():
+    ext = Extension()
+
+    schema = ext.get_config_schema()
+
+    assert "username" in schema
+    assert "password" in schema
+
+
+def test_setup():
+    ext = Extension()
+    registry = mock.Mock()
+
+    ext.setup(registry)
+
+    registry.add.assert_called_once_with(
+        "frontend", frontend_lib.WebhooksFrontend
+    )

+ 169 - 0
tests/test_frontend.py

@@ -0,0 +1,169 @@
+from unittest import mock
+
+import pytest
+
+from mopidy import models
+from mopidy_webhooks import frontend as frontend_lib
+
+
+@pytest.fixture
+def frontend():
+    config = {
+        "webhooks": {
+            "urls": "http://127.0.0.1/receiver/,http://127.0.0.1/receiver/two/",
+            "tokens": "secrettoken,anotherone",
+        }
+    }
+    core = mock.sentinel.core
+    return frontend_lib.WebhooksFrontend(config, core)
+
+
+def test_on_start_creates_lastfm_network(pylast_mock, frontend):
+    frontend.on_start()
+
+    assert frontend.webhook_urls == [
+        "http://127.0.0.1/receiver/",
+        "http://127.0.0.1/receiver/two/",
+    ]
+    assert frontend.webhook_tokens == ["secrettoken", "anotherone"]
+
+
+def test_on_start_stops_actor_on_error(pylast_mock, frontend):
+    pylast_mock.NetworkError = pylast.NetworkError
+    pylast_mock.LastFMNetwork.side_effect = pylast.NetworkError(None, "foo")
+    frontend.stop = mock.Mock()
+
+    frontend.on_start()
+
+    frontend.stop.assert_called_with()
+
+
+def test_track_playback_started_updates_now_playing(pylast_mock, frontend):
+    frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
+    artists = [models.Artist(name="ABC"), models.Artist(name="XYZ")]
+    album = models.Album(name="The Collection")
+    track = models.Track(
+        name="One Two Three",
+        artists=artists,
+        album=album,
+        track_no=3,
+        length=180432,
+        musicbrainz_id="123-456",
+    )
+    tl_track = models.TlTrack(track=track, tlid=17)
+
+    frontend.track_playback_started(tl_track)
+
+    frontend.lastfm.update_now_playing.assert_called_with(
+        "ABC, XYZ",
+        "One Two Three",
+        duration="180",
+        album="The Collection",
+        track_number="3",
+        mbid="123-456",
+    )
+
+
+def test_track_playback_started_has_default_values(pylast_mock, frontend):
+    frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
+    track = models.Track()
+    tl_track = models.TlTrack(track=track, tlid=17)
+
+    frontend.track_playback_started(tl_track)
+
+    frontend.lastfm.update_now_playing.assert_called_with(
+        "", "", duration="0", album="", track_number="0", mbid=""
+    )
+
+
+def test_track_playback_started_catches_pylast_error(pylast_mock, frontend):
+    frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
+    pylast_mock.NetworkError = pylast.NetworkError
+    frontend.lastfm.update_now_playing.side_effect = pylast.NetworkError(
+        None, "foo"
+    )
+    track = models.Track()
+    tl_track = models.TlTrack(track=track, tlid=17)
+
+    frontend.track_playback_started(tl_track)
+
+
+def test_track_playback_ended_scrobbles_played_track(pylast_mock, frontend):
+    frontend.last_start_time = 123
+    frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
+    artists = [models.Artist(name="ABC"), models.Artist(name="XYZ")]
+    album = models.Album(name="The Collection")
+    track = models.Track(
+        name="One Two Three",
+        artists=artists,
+        album=album,
+        track_no=3,
+        length=180432,
+        musicbrainz_id="123-456",
+    )
+    tl_track = models.TlTrack(track=track, tlid=17)
+
+    frontend.track_playback_ended(tl_track, 150000)
+
+    frontend.lastfm.scrobble.assert_called_with(
+        "ABC, XYZ",
+        "One Two Three",
+        "123",
+        duration="180",
+        album="The Collection",
+        track_number="3",
+        mbid="123-456",
+    )
+
+
+def test_track_playback_ended_has_default_values(pylast_mock, frontend):
+    frontend.last_start_time = 123
+    frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
+    track = models.Track(length=180432)
+    tl_track = models.TlTrack(track=track, tlid=17)
+
+    frontend.track_playback_ended(tl_track, 150000)
+
+    frontend.lastfm.scrobble.assert_called_with(
+        "", "", "123", duration="180", album="", track_number="0", mbid=""
+    )
+
+
+def test_does_not_scrobble_tracks_shorter_than_30_sec(pylast_mock, frontend):
+    frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
+    track = models.Track(length=20432)
+    tl_track = models.TlTrack(track=track, tlid=17)
+
+    frontend.track_playback_ended(tl_track, 20432)
+
+    assert frontend.lastfm.scrobble.call_count == 0
+
+
+def test_does_not_scrobble_if_played_less_than_half(pylast_mock, frontend):
+    frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
+    track = models.Track(length=180432)
+    tl_track = models.TlTrack(track=track, tlid=17)
+
+    frontend.track_playback_ended(tl_track, 60432)
+
+    assert frontend.lastfm.scrobble.call_count == 0
+
+
+def test_does_scrobble_if_played_not_half_but_240_sec(pylast_mock, frontend):
+    frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
+    track = models.Track(length=880432)
+    tl_track = models.TlTrack(track=track, tlid=17)
+
+    frontend.track_playback_ended(tl_track, 241432)
+
+    assert frontend.lastfm.scrobble.call_count == 1
+
+
+def test_track_playback_ended_catches_pylast_error(pylast_mock, frontend):
+    frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
+    pylast_mock.NetworkError = pylast.NetworkError
+    frontend.lastfm.scrobble.side_effect = pylast.NetworkError(None, "foo")
+    track = models.Track(length=180432)
+    tl_track = models.TlTrack(track=track, tlid=17)
+
+    frontend.track_playback_ended(tl_track, 150000)

+ 19 - 0
tox.ini

@@ -0,0 +1,19 @@
+[tox]
+envlist = py37, py38, py39, check-manifest, flake8
+
+[testenv]
+sitepackages = true
+deps = .[test]
+commands =
+    python -m pytest \
+        --basetemp={envtmpdir} \
+        --cov=mopidy_webhooks--cov-report=term-missing \
+        {posargs}
+
+[testenv:check-manifest]
+deps = .[lint]
+commands = python -m check_manifest
+
+[testenv:flake8]
+deps = .[lint]
+commands = python -m flake8 --show-source --statistics