Extract podcast metadata from tags and sidecar files

This commit is contained in:
2026-09-22 21:39:33 -04:00
parent 178496582c
commit cbcc42d831
14 changed files with 884 additions and 154 deletions

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 "enabled = true" in config
assert "username =" in config
assert "password =" in config
assert "urls =" in config
assert "media_dirs =" in config
assert "podcast_dirs =" in config
def test_get_config_schema():
@ -20,8 +21,10 @@ def test_get_config_schema():
schema = ext.get_config_schema()
assert "username" in schema
assert "password" in schema
assert "urls" in schema
assert "tokens" in schema
assert "media_dirs" in schema
assert "podcast_dirs" in schema
def test_setup():

View File

@ -4,6 +4,7 @@ import pytest
from mopidy import models
from mopidy_webhooks import frontend as frontend_lib
from mopidy_webhooks import metadata as metadata_lib
@pytest.fixture
@ -12,13 +13,41 @@ def frontend():
"webhooks": {
"urls": "http://127.0.0.1/receiver/,http://127.0.0.1/receiver/two/",
"tokens": "secrettoken,anotherone",
"media_dirs": "",
"podcast_dirs": "",
}
}
core = mock.sentinel.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()
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/",
]
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):
pylast_mock.NetworkError = pylast.NetworkError
pylast_mock.LastFMNetwork.side_effect = pylast.NetworkError(None, "foo")
frontend.stop = mock.Mock()
def test_build_post_data_for_track(frontend, track):
post_data = frontend._build_post_data(track, time_position=0)
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.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):
frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
artists = [models.Artist(name="ABC"), models.Artist(name="XYZ")]
album = models.Album(name="The Collection")
@mock.patch("mopidy_webhooks.frontend.requests.post")
def test_track_playback_ended_scrobbles_played_track(
mock_post, frontend, track
):
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(
name="One Two Three",
artists=artists,
album=album,
track_no=3,
length=180432,
musicbrainz_id="123-456",
uri=f"local:track:{media_file.name}",
name="Song",
artists=[models.Artist(name="Someone", uri="local:artist:someone")],
length=180000,
)
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",
reads = []
monkeypatch.setattr(
metadata_lib,
"read_metadata",
lambda path: reads.append(path) or {"title": "Tagged"},
)
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):
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)
assert len(reads) == 1

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",
)