2023-01-08 01:08:18 -05:00
|
|
|
import logging
|
2026-09-22 21:39:33 -04:00
|
|
|
import os
|
2023-01-08 01:08:18 -05:00
|
|
|
import time
|
|
|
|
|
import json
|
2026-09-22 21:39:33 -04:00
|
|
|
from typing import Any, Dict, Optional
|
2023-01-08 01:08:18 -05:00
|
|
|
|
|
|
|
|
import pykka
|
|
|
|
|
import requests
|
|
|
|
|
from mopidy.core import CoreListener
|
|
|
|
|
|
2026-09-22 21:39:33 -04:00
|
|
|
from . import metadata as metadata_lib
|
|
|
|
|
|
2023-01-08 01:08:18 -05:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WebhooksFrontend(pykka.ThreadingActor, CoreListener):
|
|
|
|
|
def __init__(self, config, core):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.config = config
|
|
|
|
|
self.webhook_urls = []
|
2026-09-22 21:39:33 -04:00
|
|
|
self.webhook_tokens = []
|
|
|
|
|
self.media_dirs = []
|
|
|
|
|
self.podcast_dirs = []
|
|
|
|
|
self._metadata_cache: Dict[str, Dict[str, Any]] = {}
|
2023-01-08 01:08:18 -05:00
|
|
|
self.last_start_time = None
|
|
|
|
|
|
|
|
|
|
def on_start(self):
|
|
|
|
|
self.webhook_urls = self.config["webhooks"]["urls"].split(",")
|
|
|
|
|
self.webhook_tokens = self.config["webhooks"]["tokens"].split(",")
|
2026-09-22 21:39:33 -04:00
|
|
|
self.media_dirs = metadata_lib.parse_dirs(
|
|
|
|
|
self.config["webhooks"].get("media_dirs", "")
|
|
|
|
|
)
|
|
|
|
|
self.podcast_dirs = metadata_lib.parse_dirs(
|
|
|
|
|
self.config["webhooks"].get("podcast_dirs", "")
|
|
|
|
|
)
|
2023-01-16 12:34:07 -05:00
|
|
|
logger.info(f"Parsing webhook URLs and tokens: {self.webhook_urls}")
|
2023-01-08 01:08:18 -05:00
|
|
|
|
2026-09-22 21:39:33 -04:00
|
|
|
def _get_track_metadata(self, track) -> tuple:
|
|
|
|
|
"""Return ``(metadata, path)`` for a local track, or empty values."""
|
|
|
|
|
path = metadata_lib.resolve_media_path(track.uri, self.media_dirs)
|
|
|
|
|
if not path:
|
|
|
|
|
return {}, None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
cache_key = f"{path}:{int(os.path.getmtime(path))}"
|
|
|
|
|
except OSError:
|
|
|
|
|
cache_key = path
|
|
|
|
|
|
|
|
|
|
if len(self._metadata_cache) > 512:
|
|
|
|
|
self._metadata_cache.clear()
|
|
|
|
|
|
|
|
|
|
if cache_key not in self._metadata_cache:
|
|
|
|
|
self._metadata_cache[cache_key] = metadata_lib.read_metadata(path)
|
|
|
|
|
return self._metadata_cache[cache_key], path
|
|
|
|
|
|
|
|
|
|
def _build_podcast_data(
|
|
|
|
|
self, track, metadata: Dict[str, Any]
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
artists = ", ".join(sorted([a.name for a in track.artists]))
|
|
|
|
|
feed_url, guid = metadata_lib.split_podcast_uri(track.uri)
|
|
|
|
|
feed_url = metadata.get("feed_url") or feed_url
|
|
|
|
|
guid = metadata.get("guid") or guid
|
|
|
|
|
album_name = track.album.name if track.album else ""
|
|
|
|
|
|
|
|
|
|
episode_num = metadata.get("track_number") or track.track_no or 0
|
|
|
|
|
try:
|
|
|
|
|
episode_num = int(str(episode_num).split("/")[0].strip())
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
episode_num = 0
|
|
|
|
|
|
|
|
|
|
description = (
|
|
|
|
|
metadata.get("description") or metadata.get("comment") or ""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
duration_seconds = int(float(metadata.get("duration") or 0))
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
duration_seconds = 0
|
|
|
|
|
if not duration_seconds:
|
|
|
|
|
duration_seconds = track.length and track.length // 1000 or 0
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"podcast_name": metadata.get("album") or album_name,
|
|
|
|
|
"podcast_producer": metadata.get("artist") or artists,
|
|
|
|
|
"podcast_description": description,
|
|
|
|
|
"podcast_feed_url": feed_url or "",
|
|
|
|
|
"podcast_site_link": metadata.get("website") or "",
|
|
|
|
|
"episode_num": episode_num,
|
|
|
|
|
"pub_date": metadata_lib.normalize_date(metadata.get("date")),
|
|
|
|
|
"episode_description": metadata.get("comment") or description,
|
|
|
|
|
"episode_url": metadata.get("episode_url") or "",
|
|
|
|
|
"episode_guid": guid or "",
|
|
|
|
|
"duration_seconds": duration_seconds,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _build_post_data(
|
|
|
|
|
self, track, time_position: Optional[int] = None
|
|
|
|
|
) -> dict:
|
2023-01-08 02:48:16 -05:00
|
|
|
artists = ", ".join(sorted([a.name for a in track.artists]))
|
|
|
|
|
artists_list = [a for a in track.artists]
|
|
|
|
|
try:
|
|
|
|
|
musicbrainz_artist_id = artists_list[0].musicbrainz_id
|
|
|
|
|
except IndexError:
|
|
|
|
|
musicbrainz_artist_id = None
|
2023-01-08 01:08:18 -05:00
|
|
|
duration = track.length and track.length // 1000 or 0
|
2026-09-22 21:39:33 -04:00
|
|
|
album_name = track.album.name if track.album else ""
|
|
|
|
|
|
|
|
|
|
metadata, path = self._get_track_metadata(track)
|
|
|
|
|
podcast = metadata_lib.is_podcast(
|
|
|
|
|
track.uri,
|
|
|
|
|
path=path,
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
podcast_dirs=self.podcast_dirs,
|
|
|
|
|
)
|
2023-01-12 15:58:43 -05:00
|
|
|
|
2026-09-22 21:39:33 -04:00
|
|
|
name = track.name
|
|
|
|
|
track_number = track.track_no
|
|
|
|
|
if podcast:
|
|
|
|
|
name = metadata.get("title") or track.name
|
|
|
|
|
artists = metadata.get("artist") or artists
|
|
|
|
|
album_name = metadata.get("album") or album_name
|
|
|
|
|
track_number = metadata.get("track_number") or track.track_no
|
|
|
|
|
|
|
|
|
|
post_data = {
|
|
|
|
|
"name": name,
|
2023-01-08 02:48:16 -05:00
|
|
|
"artist": artists,
|
2023-01-12 15:40:33 -05:00
|
|
|
"album": album_name,
|
2026-09-22 21:39:33 -04:00
|
|
|
"track_number": track_number,
|
2023-01-08 01:08:18 -05:00
|
|
|
"run_time_ticks": track.length,
|
|
|
|
|
"run_time": str(duration),
|
2023-01-12 14:29:12 -05:00
|
|
|
"playback_time_ticks": time_position,
|
2023-01-12 15:40:33 -05:00
|
|
|
"musicbrainz_track_id": track.musicbrainz_id if track.album else "",
|
2026-09-22 21:39:33 -04:00
|
|
|
"musicbrainz_album_id": (
|
|
|
|
|
track.album.musicbrainz_id if track.album else ""
|
|
|
|
|
),
|
2023-01-08 02:48:16 -05:00
|
|
|
"musicbrainz_artist_id": musicbrainz_artist_id,
|
2023-01-12 11:18:45 -05:00
|
|
|
"mopidy_uri": track.uri,
|
2023-03-02 11:21:27 -05:00
|
|
|
"primary_artist_mopidy_uri": artists_list[0].uri,
|
2026-09-22 21:39:33 -04:00
|
|
|
"media_type": "podcast" if podcast else "track",
|
2023-01-08 01:08:18 -05:00
|
|
|
}
|
|
|
|
|
|
2026-09-22 21:39:33 -04:00
|
|
|
if podcast:
|
|
|
|
|
post_data.update(self._build_podcast_data(track, metadata))
|
|
|
|
|
|
|
|
|
|
return post_data
|
|
|
|
|
|
2023-01-08 01:08:18 -05:00
|
|
|
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:
|
2023-01-23 11:09:27 -05:00
|
|
|
headers["Authorization"] = f"Token {token}"
|
2023-01-08 01:08:18 -05:00
|
|
|
|
|
|
|
|
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
|
2023-01-08 02:48:16 -05:00
|
|
|
artists = ", ".join(sorted([a.name for a in track.artists]))
|
2023-01-08 01:08:18 -05:00
|
|
|
self.last_start_time = int(time.time())
|
2023-01-08 03:03:01 -05:00
|
|
|
logger.debug(f"Now playing track: {artists} - {track.name}")
|
2023-01-12 15:58:43 -05:00
|
|
|
post_data = self._build_post_data(tl_track.track, time_position=0)
|
2023-01-08 01:08:18 -05:00
|
|
|
|
|
|
|
|
# Build post data to send to urls
|
|
|
|
|
if not self.webhook_urls:
|
|
|
|
|
logger.info("No webhook URLS are configured ")
|
|
|
|
|
return
|
2023-01-08 02:48:16 -05:00
|
|
|
logger.info(f"Scrobbling via webhooks: {artists} - {track.name}")
|
2023-01-08 01:08:18 -05:00
|
|
|
self._post_update_to_webhooks(post_data, "started")
|
|
|
|
|
|
|
|
|
|
def track_playback_ended(self, tl_track, time_position):
|
|
|
|
|
track = tl_track.track
|
2023-01-08 02:48:16 -05:00
|
|
|
artists = ", ".join(sorted([a.name for a in track.artists]))
|
2023-01-08 01:08:18 -05:00
|
|
|
duration = track.length and track.length // 1000 or 0
|
2023-01-12 15:58:43 -05:00
|
|
|
time_position_sec = time_position // 1000
|
2023-01-08 01:08:18 -05:00
|
|
|
|
2026-09-22 21:39:33 -04:00
|
|
|
post_data = self._build_post_data(
|
|
|
|
|
tl_track.track, time_position=time_position
|
|
|
|
|
)
|
2023-01-08 01:08:18 -05:00
|
|
|
|
2023-01-12 15:58:43 -05:00
|
|
|
if time_position_sec < duration // 2 and time_position_sec < 240:
|
2023-01-08 01:08:18 -05:00
|
|
|
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
|
2023-01-08 02:48:16 -05:00
|
|
|
logger.info(
|
|
|
|
|
f"Scrobbling finished via webhooks: {artists} - {track.name}"
|
|
|
|
|
)
|
2023-01-08 01:08:18 -05:00
|
|
|
|
|
|
|
|
self._post_update_to_webhooks(post_data, "stopped")
|
2023-01-12 14:29:12 -05:00
|
|
|
|
|
|
|
|
def track_playback_paused(self, tl_track, time_position):
|
|
|
|
|
track = tl_track.track
|
|
|
|
|
artists = ", ".join(sorted([a.name for a in track.artists]))
|
|
|
|
|
duration = track.length and track.length // 1000 or 0
|
|
|
|
|
|
2026-09-22 21:39:33 -04:00
|
|
|
post_data = self._build_post_data(
|
|
|
|
|
tl_track.track, time_position=time_position
|
|
|
|
|
)
|
2023-01-12 14:29:12 -05:00
|
|
|
|
|
|
|
|
if self.last_start_time is None:
|
|
|
|
|
self.last_start_time = int(time.time()) - duration
|
2026-09-22 21:39:33 -04:00
|
|
|
logger.info(f"Scrobbling paused via webhooks: {artists} - {track.name}")
|
2023-01-12 14:29:12 -05:00
|
|
|
|
|
|
|
|
self._post_update_to_webhooks(post_data, "paused")
|
|
|
|
|
|
|
|
|
|
def track_playback_resumed(self, tl_track, time_position):
|
|
|
|
|
|
|
|
|
|
track = tl_track.track
|
|
|
|
|
artists = ", ".join(sorted([a.name for a in track.artists]))
|
|
|
|
|
self.last_start_time = int(time.time())
|
|
|
|
|
logger.debug(f"Now resuming track: {artists} - {track.name}")
|
2026-09-22 21:39:33 -04:00
|
|
|
post_data = self._build_post_data(
|
|
|
|
|
tl_track.track, time_position=time_position
|
|
|
|
|
)
|
2023-01-12 14:29:12 -05:00
|
|
|
|
|
|
|
|
# Build post data to send to urls
|
|
|
|
|
if not self.webhook_urls:
|
|
|
|
|
logger.info("No webhook URLS are configured ")
|
|
|
|
|
return
|
|
|
|
|
logger.info(f"Scrobbling via webhooks: {artists} - {track.name}")
|
|
|
|
|
self._post_update_to_webhooks(post_data, "resumed")
|