Compare commits

...

2 Commits

Author SHA1 Message Date
4cbe143693 Bump version to 0.3.0
Some checks failed
CI / test (push) Failing after 1m24s
Release / release (push) Failing after 15s
2026-09-22 21:39:33 -04:00
cbcc42d831 Extract podcast metadata from tags and sidecar files 2026-09-22 21:39:33 -04:00
14 changed files with 885 additions and 155 deletions

20
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,20 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
container: ghcr.io/mopidy/ci:7
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install package and test dependencies
run: python -m pip install -e ".[test]"
- name: Run tests
run: python -m pytest

View File

@ -30,6 +30,13 @@ jobs:
- name: Build sdist and wheel - name: Build sdist and wheel
run: python -m build run: python -m build
- name: Build release zip
run: |
set -euo pipefail
VERSION="${{ gitea.ref_name }}"
cd dist
zip "Mopidy-Webhooks-${VERSION}.zip" *.tar.gz
- name: Publish to Gitea release - name: Publish to Gitea release
env: env:
GITEA_TOKEN: ${{ secrets.GITEA }} GITEA_TOKEN: ${{ secrets.GITEA }}

View File

@ -1,11 +1,13 @@
include *.py include *.py
include *.rst include *.md
include .mailmap include .mailmap
include LICENSE include LICENSE
include MANIFEST.in include MANIFEST.in
include pyproject.toml include pyproject.toml
include setup.cfg
include tox.ini include tox.ini
recursive-include .gitea *
recursive-include .github * recursive-include .github *
include mopidy_*/ext.conf include mopidy_*/ext.conf

View File

@ -23,6 +23,8 @@ them with a comma. Tokens match the listing of urls::
enabled = true enabled = true
urls = https://example.com/api/receiver/,http://127.0.0.1:8000/webhook/ urls = https://example.com/api/receiver/,http://127.0.0.1:8000/webhook/
tokens = ,2349080989234089 tokens = ,2349080989234089
media_dirs = /var/lib/mopidy/media
podcast_dirs = /var/lib/mopidy/media/podcasts
The following configuration values are available: The following configuration values are available:
@ -31,6 +33,42 @@ The following configuration values are available:
- ``webhooks/urls``: Comma-separated list of endpoints to send play data to - ``webhooks/urls``: Comma-separated list of endpoints to send play data to
- ``webhooks/tokens``: Comma-separated list of tokens to send in the - ``webhooks/tokens``: Comma-separated list of tokens to send in the
Authorization header for each URL Authorization header for each URL
- ``webhooks/media_dirs``: Comma-separated list of local media roots used to
resolve ``local:track:``/``local:podcast:`` URIs to files so their tags can
be read. Defaults to ``/var/lib/mopidy/media``.
- ``webhooks/podcast_dirs``: Comma-separated list of directories that should
always be treated as podcasts, even when the URI does not mention it.
Podcast metadata
================
When a track is detected as a podcast episode, the webhook payload is enriched
with metadata read from the audio file itself, so the receiver no longer has to
guess the podcast, episode number or publication date from the file name.
Sources, in order of preference:
1. Audio tags (ID3, Vorbis, MP4) read with `mutagen
<https://mutagen.readthedocs.io/>`_: title, artist, album, track number,
date, genre, comment/description, publisher, language, copyright and
website.
2. A sidecar JSON file written next to the episode (``<episode>.json``) or in
a ``metadata/`` directory (``metadata/<episode>.json``), as produced by
common podcatchers. This provides ``feed_url``, ``guid``, ``episode_url``,
``duration``, ``image`` and a fallback ``description``.
Podcast episodes are detected when the URI contains ``podcast``, when the file
lives under one of the ``webhooks/podcast_dirs``, or when the file has a
sidecar JSON with a feed URL or guid.
In addition to the regular fields, podcast payloads include:
- ``media_type``: ``"podcast"`` or ``"track"``
- ``podcast_name``, ``podcast_producer``, ``podcast_description``
- ``podcast_feed_url``, ``podcast_site_link``
- ``episode_num``, ``pub_date`` (``YYYY-MM-DD``)
- ``episode_description``, ``episode_url``, ``episode_guid``
Project resources Project resources

View File

@ -20,6 +20,8 @@ class Extension(ext.Extension):
schema = super().get_config_schema() schema = super().get_config_schema()
schema["urls"] = config.String() schema["urls"] = config.String()
schema["tokens"] = config.Secret(optional=True) schema["tokens"] = config.Secret(optional=True)
schema["media_dirs"] = config.String(optional=True)
schema["podcast_dirs"] = config.String(optional=True)
return schema return schema
def setup(self, registry): def setup(self, registry):

View File

@ -2,3 +2,5 @@
enabled = true enabled = true
urls = "" urls = ""
tokens = "" tokens = ""
media_dirs = /var/lib/mopidy/media
podcast_dirs = ""

View File

@ -1,12 +1,15 @@
import logging import logging
import os
import time import time
import json import json
from typing import Optional from typing import Any, Dict, Optional
import pykka import pykka
import requests import requests
from mopidy.core import CoreListener from mopidy.core import CoreListener
from . import metadata as metadata_lib
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -15,14 +18,84 @@ class WebhooksFrontend(pykka.ThreadingActor, CoreListener):
super().__init__() super().__init__()
self.config = config self.config = config
self.webhook_urls = [] self.webhook_urls = []
self.webhook_tokens = []
self.media_dirs = []
self.podcast_dirs = []
self._metadata_cache: Dict[str, Dict[str, Any]] = {}
self.last_start_time = None self.last_start_time = None
def on_start(self): def on_start(self):
self.webhook_urls = self.config["webhooks"]["urls"].split(",") self.webhook_urls = self.config["webhooks"]["urls"].split(",")
self.webhook_tokens = self.config["webhooks"]["tokens"].split(",") self.webhook_tokens = self.config["webhooks"]["tokens"].split(",")
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", "")
)
logger.info(f"Parsing webhook URLs and tokens: {self.webhook_urls}") logger.info(f"Parsing webhook URLs and tokens: {self.webhook_urls}")
def _build_post_data(self, track, time_position: Optional[int]=None) -> dict: 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:
artists = ", ".join(sorted([a.name for a in track.artists])) artists = ", ".join(sorted([a.name for a in track.artists]))
artists_list = [a for a in track.artists] artists_list = [a for a in track.artists]
try: try:
@ -30,25 +103,47 @@ class WebhooksFrontend(pykka.ThreadingActor, CoreListener):
except IndexError: except IndexError:
musicbrainz_artist_id = None musicbrainz_artist_id = None
duration = track.length and track.length // 1000 or 0 duration = track.length and track.length // 1000 or 0
album_name = "" album_name = track.album.name if track.album else ""
if track.album:
album_name = getattr(track.album, "name")
return { metadata, path = self._get_track_metadata(track)
"name": track.name, podcast = metadata_lib.is_podcast(
track.uri,
path=path,
metadata=metadata,
podcast_dirs=self.podcast_dirs,
)
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,
"artist": artists, "artist": artists,
"album": album_name, "album": album_name,
"track_number": track.track_no, "track_number": track_number,
"run_time_ticks": track.length, "run_time_ticks": track.length,
"run_time": str(duration), "run_time": str(duration),
"playback_time_ticks": time_position, "playback_time_ticks": time_position,
"musicbrainz_track_id": track.musicbrainz_id if track.album else "", "musicbrainz_track_id": track.musicbrainz_id if track.album else "",
"musicbrainz_album_id": track.album.musicbrainz_id if track.album else "", "musicbrainz_album_id": (
track.album.musicbrainz_id if track.album else ""
),
"musicbrainz_artist_id": musicbrainz_artist_id, "musicbrainz_artist_id": musicbrainz_artist_id,
"mopidy_uri": track.uri, "mopidy_uri": track.uri,
"primary_artist_mopidy_uri": artists_list[0].uri, "primary_artist_mopidy_uri": artists_list[0].uri,
"media_type": "podcast" if podcast else "track",
} }
if podcast:
post_data.update(self._build_podcast_data(track, metadata))
return post_data
def _post_update_to_webhooks(self, post_data: dict, status: str): def _post_update_to_webhooks(self, post_data: dict, status: str):
post_data["status"] = status post_data["status"] = status
@ -88,7 +183,9 @@ class WebhooksFrontend(pykka.ThreadingActor, CoreListener):
duration = track.length and track.length // 1000 or 0 duration = track.length and track.length // 1000 or 0
time_position_sec = time_position // 1000 time_position_sec = time_position // 1000
post_data = self._build_post_data(tl_track.track, time_position=time_position) post_data = self._build_post_data(
tl_track.track, time_position=time_position
)
if time_position_sec < duration // 2 and time_position_sec < 240: if time_position_sec < duration // 2 and time_position_sec < 240:
logger.debug( logger.debug(
@ -108,15 +205,14 @@ class WebhooksFrontend(pykka.ThreadingActor, CoreListener):
track = tl_track.track track = tl_track.track
artists = ", ".join(sorted([a.name for a in track.artists])) artists = ", ".join(sorted([a.name for a in track.artists]))
duration = track.length and track.length // 1000 or 0 duration = track.length and track.length // 1000 or 0
time_position_sec = time_position // 1000
post_data = self._build_post_data(tl_track.track, time_position=time_position) post_data = self._build_post_data(
tl_track.track, time_position=time_position
)
if self.last_start_time is None: if self.last_start_time is None:
self.last_start_time = int(time.time()) - duration self.last_start_time = int(time.time()) - duration
logger.info( logger.info(f"Scrobbling paused via webhooks: {artists} - {track.name}")
f"Scrobbling paused via webhooks: {artists} - {track.name}"
)
self._post_update_to_webhooks(post_data, "paused") self._post_update_to_webhooks(post_data, "paused")
@ -126,7 +222,9 @@ class WebhooksFrontend(pykka.ThreadingActor, CoreListener):
artists = ", ".join(sorted([a.name for a in track.artists])) artists = ", ".join(sorted([a.name for a in track.artists]))
self.last_start_time = int(time.time()) self.last_start_time = int(time.time())
logger.debug(f"Now resuming track: {artists} - {track.name}") logger.debug(f"Now resuming track: {artists} - {track.name}")
post_data = self._build_post_data(tl_track.track, time_position=time_position) post_data = self._build_post_data(
tl_track.track, time_position=time_position
)
# Build post data to send to urls # Build post data to send to urls
if not self.webhook_urls: if not self.webhook_urls:

309
mopidy_webhooks/metadata.py Normal file
View File

@ -0,0 +1,309 @@
import json
import logging
import os
import re
from typing import Any, Dict, Iterable, List, Optional
from urllib.parse import unquote, urlparse
from urllib.request import url2pathname
try:
import mutagen
except ImportError: # pragma: no cover
mutagen = None
logger = logging.getLogger(__name__)
PODCAST_URI_PREFIX = "podcast+"
_LOCAL_URI_TYPES = frozenset(
{"album", "artist", "directory", "image", "podcast", "track"}
)
_SIMPLE_TAG_KEYS = {
"title": ("title",),
"artist": ("artist", "albumartist"),
"album": ("album",),
"track_number": ("tracknumber",),
"date": ("date", "year"),
"genre": ("genre",),
"publisher": ("publisher", "organization"),
"language": ("language",),
"copyright": ("copyright",),
}
# Fields that are common in podcast files but not exposed by mutagen's
# "easy" interface (notably the ID3 COMM frame holding the description).
_RAW_TAG_KEYS = {
"comment": (
"comment",
"COMM::eng",
"COMM",
"description",
"desc",
"\xa9cmt",
),
"website": ("website", "WXXX", "WOAR", "url", "\xa9url"),
}
_SIDECAR_KEYS = (
"feed_url",
"guid",
"episode_url",
"description",
"duration",
"image",
)
_DATE_RE = re.compile(r"(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?")
def parse_dirs(value: Any) -> List[str]:
"""Split a comma-separated config value into a list of directories."""
if not value:
return []
if isinstance(value, (list, tuple)):
values = value
else:
values = str(value).split(",")
return [
str(directory).strip() for directory in values if str(directory).strip()
]
def split_podcast_uri(uri: Optional[str]) -> tuple:
"""Split a ``podcast+<feed>#<guid>`` URI into its feed and guid parts."""
if not uri or not uri.startswith(PODCAST_URI_PREFIX):
return None, None
feed_url, _, guid = uri[len(PODCAST_URI_PREFIX) :].partition("#")
return feed_url or None, guid or None
def _local_relative_path(uri: str) -> Optional[str]:
rest = uri.split(":", 1)[1] if ":" in uri else uri
if ":" in rest:
kind, relative = rest.split(":", 1)
if kind in _LOCAL_URI_TYPES:
return relative
return rest
def resolve_media_path(
uri: Optional[str], media_dirs: Optional[Iterable[str]] = None
) -> Optional[str]:
"""Resolve a Mopidy track URI to a local filesystem path if possible."""
if not uri:
return None
parsed = urlparse(uri)
if parsed.scheme == "file":
path = url2pathname(parsed.path)
return path or None
if parsed.scheme != "local":
return None
relative = _local_relative_path(uri)
if not relative:
return None
relative = unquote(relative)
if os.path.isabs(relative):
return relative
directories = list(media_dirs or [])
for media_dir in directories:
candidate = os.path.join(media_dir, relative)
if os.path.exists(candidate):
return candidate
if directories:
return os.path.join(directories[0], relative)
return None
def is_under(path: Optional[str], directories: Iterable[str]) -> bool:
"""Return True when ``path`` lives under one of ``directories``."""
if not path:
return False
real_path = os.path.realpath(path)
for directory in directories:
real_dir = os.path.realpath(directory)
if real_path == real_dir or real_path.startswith(real_dir + os.sep):
return True
return False
def _frame_text(frame: Any) -> Optional[str]:
text = getattr(frame, "text", None)
if text:
value = text[0] if isinstance(text, (list, tuple)) else text
return str(value).strip() or None
if isinstance(frame, (list, tuple)):
return str(frame[0]).strip() or None
return str(frame).strip() or None
def _first_raw_tag_value(path: str, keys: Iterable[str]) -> Optional[str]:
if mutagen is None:
return None
try:
audio = mutagen.File(path)
except Exception: # pragma: no cover - defensive against bad files
return None
tags = getattr(audio, "tags", None)
if not tags:
return None
for key in keys:
frame = None
try:
frame = tags.get(key)
except Exception:
frame = None
if frame is None and hasattr(tags, "getall"):
try:
frames = tags.getall(key)
except Exception:
frames = []
frame = frames[0] if frames else None
if frame is None:
continue
value = _frame_text(frame)
if value:
return value
return None
def _first_tag_value(audio: Any, keys: Iterable[str]) -> Optional[str]:
for key in keys:
try:
value = audio.get(key)
except Exception: # pragma: no cover - defensive against bad tags
continue
if not value:
continue
if isinstance(value, (list, tuple)):
value = value[0]
value = str(value).strip()
if value:
return value
return None
def read_tags(path: Optional[str]) -> Dict[str, str]:
"""Read normalized tags from an audio file with mutagen."""
if mutagen is None or not path or not os.path.isfile(path):
return {}
try:
audio = mutagen.File(path, easy=True)
except Exception:
logger.warning("Could not read audio tags from %s", path, exc_info=True)
return {}
if audio is None:
return {}
metadata: Dict[str, str] = {}
for field, keys in _SIMPLE_TAG_KEYS.items():
value = _first_tag_value(audio, keys)
if value:
metadata[field] = value
for field, keys in _RAW_TAG_KEYS.items():
if metadata.get(field):
continue
value = _first_raw_tag_value(path, keys)
if value:
metadata[field] = value
track_number = metadata.get("track_number")
if track_number:
metadata["track_number"] = track_number.split("/")[0].strip()
return metadata
def sidecar_paths(path: str) -> List[str]:
"""Return the candidate sidecar JSON paths for an audio file."""
stem, _ = os.path.splitext(path)
return [
stem + ".json",
os.path.join(
os.path.dirname(path), "metadata", os.path.basename(stem) + ".json"
),
]
def read_sidecar_json(path: str) -> Dict[str, Any]:
"""Read a podcatcher/yt-dlp style sidecar JSON file if present."""
for candidate in sidecar_paths(path):
if not os.path.isfile(candidate):
continue
try:
with open(candidate, encoding="utf-8") as sidecar:
data = json.load(sidecar)
except (OSError, ValueError):
logger.warning("Could not read sidecar metadata from %s", candidate)
continue
if isinstance(data, dict):
return data
return {}
def normalize_date(value: Any) -> str:
"""Normalize a tag/sidecar date to a ``YYYY-MM-DD`` string when possible."""
if not value:
return ""
if hasattr(value, "strftime"):
return value.strftime("%Y-%m-%d")
match = _DATE_RE.search(str(value))
if not match or not match.group(2):
return ""
year, month, day = match.groups()
return "-".join((year, month, day or "01"))
def read_metadata(path: Optional[str]) -> Dict[str, Any]:
"""Read tags and sidecar metadata for a local media file.
Audio tags win for title/artist/album/genre, while the sidecar JSON is the
source for podcast specific fields such as the feed URL and episode guid.
"""
if not path:
return {}
metadata: Dict[str, Any] = {}
sidecar = read_sidecar_json(path)
for key in _SIDECAR_KEYS:
value = sidecar.get(key)
if value:
metadata[key] = value
for field in ("title", "artist", "album", "date"):
value = sidecar.get(field)
if value:
metadata.setdefault(field, str(value))
metadata.update(
{key: value for key, value in read_tags(path).items() if value}
)
if sidecar.get("date"):
metadata["date"] = str(sidecar["date"])
return metadata
def is_podcast(
uri: Optional[str],
path: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
podcast_dirs: Optional[Iterable[str]] = None,
) -> bool:
"""Best effort detection of podcast episodes."""
uri = uri or ""
if PODCAST_URI_PREFIX in uri or "podcast" in uri.lower():
return True
if path and is_under(path, podcast_dirs or []):
return True
metadata = metadata or {}
return bool(metadata.get("feed_url") or metadata.get("guid"))

View File

@ -1,13 +1,14 @@
[metadata] [metadata]
name = Mopidy-Webhooks name = Mopidy-Webhooks
version = 0.2.3 version = 0.3.0
url = https://github.com/powellc/mopidy-webhooks url = https://github.com/powellc/mopidy-webhooks
author = Colin Powell author = Colin Powell
author_email = colin@unbl.ink author_email = colin@unbl.ink
license = Apache License, Version 2.0 license = Apache License, Version 2.0
license_file = LICENSE license_file = LICENSE
description = Mopidy extension for sending playback data to webhook urls description = Mopidy extension for sending playback data to webhook urls
long_description = file: README.rst long_description = file: README.md
long_description_content_type = text/markdown
classifiers = classifiers =
Environment :: No Input/Output (Daemon) Environment :: No Input/Output (Daemon)
Intended Audience :: End Users/Desktop Intended Audience :: End Users/Desktop
@ -28,6 +29,7 @@ python_requires = >= 3.7
install_requires = install_requires =
Mopidy >= 3.0.0 Mopidy >= 3.0.0
Pykka >= 2.0.1 Pykka >= 2.0.1
mutagen >= 1.45
setuptools setuptools

BIN
tests/data/silence.mp3 Normal file

Binary file not shown.

View File

@ -11,8 +11,9 @@ def test_get_default_config():
assert "[webhooks]" in config assert "[webhooks]" in config
assert "enabled = true" in config assert "enabled = true" in config
assert "username =" in config assert "urls =" in config
assert "password =" in config assert "media_dirs =" in config
assert "podcast_dirs =" in config
def test_get_config_schema(): def test_get_config_schema():
@ -20,8 +21,10 @@ def test_get_config_schema():
schema = ext.get_config_schema() schema = ext.get_config_schema()
assert "username" in schema assert "urls" in schema
assert "password" in schema assert "tokens" in schema
assert "media_dirs" in schema
assert "podcast_dirs" in schema
def test_setup(): def test_setup():

View File

@ -4,6 +4,7 @@ import pytest
from mopidy import models from mopidy import models
from mopidy_webhooks import frontend as frontend_lib from mopidy_webhooks import frontend as frontend_lib
from mopidy_webhooks import metadata as metadata_lib
@pytest.fixture @pytest.fixture
@ -12,13 +13,41 @@ def frontend():
"webhooks": { "webhooks": {
"urls": "http://127.0.0.1/receiver/,http://127.0.0.1/receiver/two/", "urls": "http://127.0.0.1/receiver/,http://127.0.0.1/receiver/two/",
"tokens": "secrettoken,anotherone", "tokens": "secrettoken,anotherone",
"media_dirs": "",
"podcast_dirs": "",
} }
} }
core = mock.sentinel.core core = mock.sentinel.core
return frontend_lib.WebhooksFrontend(config, core) return frontend_lib.WebhooksFrontend(config, core)
def test_on_start_creates_lastfm_network(pylast_mock, frontend): @pytest.fixture
def track():
return models.Track(
uri="local:track:Sublime%20-%20Sublime/Disc%201%20-%2004%20-%20Same%20in%20the%20End.mp3",
name="Same in the End",
artists=[models.Artist(name="Sublime", uri="local:artist:md5:abc")],
album=models.Album(name="Sublime"),
track_no=4,
length=156604,
)
@pytest.fixture
def podcast_track():
return models.Track(
uri="local:podcast:TED%20Talks/2026-09-22_Do%20the%20hard%20thing.mp3",
name="2026-09-22_Do the hard thing",
artists=[models.Artist(name="TED Talks Daily")],
album=models.Album(name="TED Talks Daily"),
length=1800000,
)
def test_on_start_parses_urls_tokens_and_dirs(frontend):
frontend.config["webhooks"]["media_dirs"] = "/media,/srv/media"
frontend.config["webhooks"]["podcast_dirs"] = "/media/podcasts"
frontend.on_start() frontend.on_start()
assert frontend.webhook_urls == [ assert frontend.webhook_urls == [
@ -26,144 +55,176 @@ def test_on_start_creates_lastfm_network(pylast_mock, frontend):
"http://127.0.0.1/receiver/two/", "http://127.0.0.1/receiver/two/",
] ]
assert frontend.webhook_tokens == ["secrettoken", "anotherone"] assert frontend.webhook_tokens == ["secrettoken", "anotherone"]
assert frontend.media_dirs == ["/media", "/srv/media"]
assert frontend.podcast_dirs == ["/media/podcasts"]
def test_on_start_stops_actor_on_error(pylast_mock, frontend): def test_build_post_data_for_track(frontend, track):
pylast_mock.NetworkError = pylast.NetworkError post_data = frontend._build_post_data(track, time_position=0)
pylast_mock.LastFMNetwork.side_effect = pylast.NetworkError(None, "foo")
frontend.stop = mock.Mock()
assert post_data["name"] == "Same in the End"
assert post_data["artist"] == "Sublime"
assert post_data["album"] == "Sublime"
assert post_data["track_number"] == 4
assert post_data["run_time"] == "156"
assert post_data["media_type"] == "track"
assert "podcast_name" not in post_data
def test_build_post_data_for_podcast_prefers_tags(
frontend, podcast_track, monkeypatch
):
monkeypatch.setattr(
metadata_lib,
"resolve_media_path",
lambda uri, dirs: "/media/podcasts/ep.mp3",
)
monkeypatch.setattr(
metadata_lib,
"read_metadata",
lambda path: {
"title": "#564: EVE Online Departs for Python 3",
"artist": "Michael Kennedy",
"album": "Talk Python To Me",
"track_number": "564",
"date": "2026-09-11T15:46:15",
"comment": "A great episode",
"feed_url": "https://talkpython.fm/episodes/rss",
"guid": "talk-python-564",
"episode_url": "https://talkpython.fm/564",
"duration": 4071,
},
)
monkeypatch.setattr(
metadata_lib, "is_podcast", lambda uri, **kwargs: "podcast" in uri
)
post_data = frontend._build_post_data(podcast_track, time_position=0)
assert post_data["media_type"] == "podcast"
assert post_data["name"] == "#564: EVE Online Departs for Python 3"
assert post_data["artist"] == "Michael Kennedy"
assert post_data["album"] == "Talk Python To Me"
assert post_data["track_number"] == "564"
assert post_data["podcast_name"] == "Talk Python To Me"
assert post_data["podcast_producer"] == "Michael Kennedy"
assert post_data["podcast_feed_url"] == "https://talkpython.fm/episodes/rss"
assert post_data["podcast_description"] == "A great episode"
assert post_data["episode_num"] == 564
assert post_data["pub_date"] == "2026-09-11"
assert post_data["episode_url"] == "https://talkpython.fm/564"
assert post_data["episode_guid"] == "talk-python-564"
assert post_data["duration_seconds"] == 4071
def test_build_post_data_for_podcast_falls_back_to_track(
frontend, podcast_track
):
post_data = frontend._build_post_data(podcast_track, time_position=0)
assert post_data["media_type"] == "podcast"
assert post_data["name"] == podcast_track.name
assert post_data["podcast_name"] == "TED Talks Daily"
assert post_data["podcast_producer"] == "TED Talks Daily"
assert post_data["podcast_feed_url"] == ""
def test_build_post_data_for_remote_podcast_uri(frontend):
track = models.Track(
uri="podcast+https://feeds.npr.org/510318/podcast.xml#85b9c4c4-guid",
name="UN General Assembly Week",
artists=[models.Artist(name="NPR")],
album=models.Album(name="Up First"),
length=900000,
)
post_data = frontend._build_post_data(track, time_position=0)
assert post_data["media_type"] == "podcast"
assert post_data["podcast_name"] == "Up First"
assert (
post_data["podcast_feed_url"]
== "https://feeds.npr.org/510318/podcast.xml"
)
assert post_data["episode_guid"] == "85b9c4c4-guid"
@mock.patch("mopidy_webhooks.frontend.requests.post")
def test_track_playback_started_posts_started(mock_post, frontend, track):
frontend.on_start() frontend.on_start()
frontend.stop.assert_called_with() frontend.track_playback_started(models.TlTrack(track=track, tlid=1))
assert mock_post.call_count == 2
first_call = mock_post.call_args_list[0]
assert first_call.args[0] == "http://127.0.0.1/receiver/"
assert first_call.kwargs["headers"] == {
"Authorization": "Token secrettoken"
}
assert '"status": "started"' in first_call.kwargs["json"]
def test_track_playback_started_updates_now_playing(pylast_mock, frontend): @mock.patch("mopidy_webhooks.frontend.requests.post")
frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork) def test_track_playback_ended_scrobbles_played_track(
artists = [models.Artist(name="ABC"), models.Artist(name="XYZ")] mock_post, frontend, track
album = models.Album(name="The Collection") ):
frontend.on_start()
frontend.track_playback_ended(models.TlTrack(track=track, tlid=1), 150000)
assert mock_post.call_count == 2
assert '"status": "stopped"' in mock_post.call_args.kwargs["json"]
@mock.patch("mopidy_webhooks.frontend.requests.post")
def test_does_not_scrobble_if_played_less_than_half(mock_post, frontend, track):
frontend.on_start()
frontend.track_playback_ended(models.TlTrack(track=track, tlid=1), 60432)
assert mock_post.call_count == 0
@mock.patch("mopidy_webhooks.frontend.requests.post")
def test_track_playback_paused_and_resumed_post(mock_post, frontend, track):
frontend.on_start()
frontend.track_playback_paused(models.TlTrack(track=track, tlid=1), 15000)
frontend.track_playback_resumed(models.TlTrack(track=track, tlid=1), 15000)
statuses = [call.kwargs["json"] for call in mock_post.call_args_list]
assert any('"status": "paused"' in payload for payload in statuses)
assert any('"status": "resumed"' in payload for payload in statuses)
@mock.patch("mopidy_webhooks.frontend.requests.post")
def test_no_urls_configured_posts_nothing(mock_post, frontend, track):
frontend.on_start()
frontend.webhook_urls = []
frontend.track_playback_started(models.TlTrack(track=track, tlid=1))
assert mock_post.call_count == 0
def test_metadata_cache_avoids_repeated_reads(frontend, monkeypatch, tmp_path):
media_file = tmp_path / "song.mp3"
media_file.write_bytes(b"data")
frontend.media_dirs = [str(tmp_path)]
track = models.Track( track = models.Track(
name="One Two Three", uri=f"local:track:{media_file.name}",
artists=artists, name="Song",
album=album, artists=[models.Artist(name="Someone", uri="local:artist:someone")],
track_no=3, length=180000,
length=180432,
musicbrainz_id="123-456",
) )
tl_track = models.TlTrack(track=track, tlid=17) reads = []
monkeypatch.setattr(
frontend.track_playback_started(tl_track) metadata_lib,
"read_metadata",
frontend.lastfm.update_now_playing.assert_called_with( lambda path: reads.append(path) or {"title": "Tagged"},
"ABC, XYZ",
"One Two Three",
duration="180",
album="The Collection",
track_number="3",
mbid="123-456",
) )
frontend._build_post_data(track, time_position=0)
frontend._build_post_data(track, time_position=1000)
def test_track_playback_started_has_default_values(pylast_mock, frontend): assert len(reads) == 1
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)

186
tests/test_metadata.py Normal file
View File

@ -0,0 +1,186 @@
import json
import shutil
from pathlib import Path
import pytest
from mutagen import File as MutagenFile
from mutagen.id3 import COMM, WXXX
from mopidy_webhooks import metadata
DATA_DIR = Path(__file__).parent / "data"
@pytest.fixture
def tagged_episode(tmp_path):
episode = tmp_path / "2026-09-22 Episode One.mp3"
shutil.copy(DATA_DIR / "silence.mp3", episode)
audio = MutagenFile(episode, easy=True)
audio["title"] = ["Episode One"]
audio["artist"] = ["Jane Host"]
audio["album"] = ["The Test Podcast"]
audio["tracknumber"] = ["42"]
audio["date"] = ["2026-09-22"]
audio["genre"] = ["Technology"]
audio.save()
raw = MutagenFile(episode)
raw.tags.add(
COMM(encoding=3, lang="eng", desc="", text="A tag description")
)
raw.tags.add(WXXX(encoding=3, desc="", url="https://example.com/episode"))
raw.save()
return episode
def test_resolve_media_path_for_file_uri(tagged_episode):
path = metadata.resolve_media_path(tagged_episode.as_uri(), [])
assert path == str(tagged_episode)
def test_resolve_media_path_for_local_uri(tagged_episode, tmp_path):
uri = f"local:podcast:{tagged_episode.name}"
path = metadata.resolve_media_path(uri, [str(tmp_path)])
assert path == str(tagged_episode)
def test_resolve_media_path_for_local_track_uri(tagged_episode, tmp_path):
uri = f"local:track:{tagged_episode.name}"
path = metadata.resolve_media_path(uri, [str(tmp_path)])
assert path == str(tagged_episode)
def test_resolve_media_path_percent_decodes_uri(tagged_episode, tmp_path):
uri = "local:podcast:2026-09-22%20Episode%20One.mp3"
path = metadata.resolve_media_path(uri, [str(tmp_path)])
assert path == str(tagged_episode)
def test_resolve_media_path_returns_none_for_remote_uri():
assert metadata.resolve_media_path("spotify:track:123", []) is None
assert (
metadata.resolve_media_path("podcast+https://example.com/feed#1", [])
is None
)
assert metadata.resolve_media_path("", []) is None
def test_read_tags_returns_normalized_metadata(tagged_episode):
tags = metadata.read_tags(str(tagged_episode))
assert tags["title"] == "Episode One"
assert tags["artist"] == "Jane Host"
assert tags["album"] == "The Test Podcast"
assert tags["track_number"] == "42"
assert tags["date"] == "2026-09-22"
assert tags["genre"] == "Technology"
assert tags["comment"] == "A tag description"
assert tags["website"] == "https://example.com/episode"
def test_read_tags_handles_missing_file():
assert metadata.read_tags("/does/not/exist.mp3") == {}
assert metadata.read_tags(None) == {}
def test_read_sidecar_json_prefers_same_directory(tagged_episode):
sidecar = tagged_episode.with_suffix(".json")
sidecar.write_text(json.dumps({"feed_url": "https://example.com/feed"}))
data = metadata.read_sidecar_json(str(tagged_episode))
assert data["feed_url"] == "https://example.com/feed"
def test_read_metadata_merges_sidecar_and_tags(tagged_episode, tmp_path):
metadata_dir = tmp_path / "metadata"
metadata_dir.mkdir()
sidecar = metadata_dir / f"{tagged_episode.stem}.json"
sidecar.write_text(
json.dumps(
{
"title": "Sidecar Title",
"date": "2026-09-21T10:00:00",
"feed_url": "https://example.com/feed",
"guid": "episode-guid",
"episode_url": "https://example.com/episode.mp3",
"description": "A sidecar description",
"duration": 1234,
}
)
)
data = metadata.read_metadata(str(tagged_episode))
assert data["title"] == "Episode One"
assert data["artist"] == "Jane Host"
assert data["album"] == "The Test Podcast"
assert data["feed_url"] == "https://example.com/feed"
assert data["guid"] == "episode-guid"
assert data["episode_url"] == "https://example.com/episode.mp3"
assert data["description"] == "A sidecar description"
assert data["duration"] == 1234
assert data["date"] == "2026-09-21T10:00:00"
@pytest.mark.parametrize(
"value,expected",
[
("2026-09-22", "2026-09-22"),
("2026-09-22T10:00:00", "2026-09-22"),
("2026-09", "2026-09-01"),
("2026", ""),
("", ""),
("not a date", ""),
],
)
def test_normalize_date(value, expected):
assert metadata.normalize_date(value) == expected
def test_normalize_date_handles_date_objects():
import datetime
assert metadata.normalize_date(datetime.date(2026, 9, 22)) == "2026-09-22"
def test_split_podcast_uri():
feed, guid = metadata.split_podcast_uri(
"podcast+https://feed.example/rss#abc"
)
assert feed == "https://feed.example/rss"
assert guid == "abc"
assert metadata.split_podcast_uri("local:track:foo.mp3") == (None, None)
def test_parse_dirs():
assert metadata.parse_dirs("/a, /b ,, ") == ["/a", "/b"]
assert metadata.parse_dirs("") == []
assert metadata.parse_dirs(None) == []
assert metadata.parse_dirs(["/a", "/b"]) == ["/a", "/b"]
def test_is_podcast_detection(tagged_episode, tmp_path):
assert metadata.is_podcast("local:podcast:foo.mp3")
assert metadata.is_podcast("podcast+https://feed.example/rss#abc")
assert metadata.is_podcast("file:///media/podcasts/foo.mp3")
assert metadata.is_podcast(
"file:///tmp/foo.mp3",
path=str(tagged_episode),
podcast_dirs=[str(tmp_path)],
)
assert metadata.is_podcast(
"file:///tmp/foo.mp3", path="/tmp/foo.mp3", metadata={"feed_url": "x"}
)
assert not metadata.is_podcast(
"local:track:Artist/Album/01%20Song.mp3",
path="/tmp/song.mp3",
)

View File

@ -1,5 +1,5 @@
[tox] [tox]
envlist = py37, py38, py39, check-manifest, flake8 envlist = py310, py311, py312, py313, check-manifest, flake8
[testenv] [testenv]
sitepackages = true sitepackages = true
@ -7,7 +7,7 @@ deps = .[test]
commands = commands =
python -m pytest \ python -m pytest \
--basetemp={envtmpdir} \ --basetemp={envtmpdir} \
--cov=mopidy_webhooks--cov-report=term-missing \ --cov=mopidy_webhooks --cov-report=term-missing \
{posargs} {posargs}
[testenv:check-manifest] [testenv:check-manifest]