commit bbd1f5b5b71fb0fd80fea605a022632ac1827c05 Author: Colin Powell Date: Sun Jan 8 01:08:18 2023 -0500 Initial commit Originally based off of mopidy-scrobbler code diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03640c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.pyc +/.coverage +/.mypy_cache/ +/.pytest_cache/ +/.tox/ +/*.egg-info +/build/ +/dist/ +/MANIFEST diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/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. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9d9fd9b --- /dev/null +++ b/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 * diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..316ea1f --- /dev/null +++ b/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 `_ 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 `_ +- `Issue tracker `_ +- `Changelog `_ + + +Credits +======= + +- Author: `Colin Powell `__ diff --git a/mopidy_webhooks/__init__.py b/mopidy_webhooks/__init__.py new file mode 100644 index 0000000..a31c644 --- /dev/null +++ b/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) diff --git a/mopidy_webhooks/ext.conf b/mopidy_webhooks/ext.conf new file mode 100644 index 0000000..f1821ee --- /dev/null +++ b/mopidy_webhooks/ext.conf @@ -0,0 +1,4 @@ +[webhooks] +enabled = true +urls = "" +tokens = "" diff --git a/mopidy_webhooks/frontend.py b/mopidy_webhooks/frontend.py new file mode 100644 index 0000000..6f0e5b1 --- /dev/null +++ b/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") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b707e99 --- /dev/null +++ b/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" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..750eca0 --- /dev/null +++ b/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 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6068493 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_extension.py b/tests/test_extension.py new file mode 100644 index 0000000..24bb5ff --- /dev/null +++ b/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 + ) diff --git a/tests/test_frontend.py b/tests/test_frontend.py new file mode 100644 index 0000000..156e71b --- /dev/null +++ b/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) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..565e27a --- /dev/null +++ b/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