From dfbe07b323092ea79f300620d2a15106d163cb85 Mon Sep 17 00:00:00 2001 From: Lucas Bickel <116588+hairmare@users.noreply.github.com> Date: Mon, 11 Dec 2023 19:18:50 +0100 Subject: [PATCH] feat(output): implement SMC FTP support (#552) --- nowplaying/daemon.py | 10 ++ nowplaying/options.py | 2 + .../track/observers/dab_audio_companion.py | 2 +- nowplaying/track/observers/smc_ftp.py | 102 ++++++++++++++++++ tests/test_track_observer_smc_ftp.py | 92 ++++++++++++++++ 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 nowplaying/track/observers/smc_ftp.py create mode 100644 tests/test_track_observer_smc_ftp.py diff --git a/nowplaying/daemon.py b/nowplaying/daemon.py index fc06d722..d7b3b38f 100644 --- a/nowplaying/daemon.py +++ b/nowplaying/daemon.py @@ -15,6 +15,7 @@ from .track.handler import TrackEventHandler from .track.observers.dab_audio_companion import DabAudioCompanionTrackObserver from .track.observers.icecast import IcecastTrackObserver +from .track.observers.smc_ftp import SmcFtpTrackObserver from .track.observers.ticker import TickerTrackObserver logger = logging.getLogger(__name__) @@ -136,6 +137,15 @@ def get_track_handler(self): # pragma: no cover ) ) ) + handler.register_observer( + SmcFtpTrackObserver( + options=SmcFtpTrackObserver.Options( + hostname=self.options.dab_smc_ftp_hostname, + username=self.options.dab_smc_ftp_username, + password=self.options.dab_smc_ftp_password, + ) + ) + ) return handler diff --git a/nowplaying/options.py b/nowplaying/options.py index 2d3bfbdc..da212c94 100644 --- a/nowplaying/options.py +++ b/nowplaying/options.py @@ -4,6 +4,7 @@ DabAudioCompanionTrackObserver, ) from nowplaying.track.observers.icecast import IcecastTrackObserver +from nowplaying.track.observers.smc_ftp import SmcFtpTrackObserver from nowplaying.track.observers.ticker import TickerTrackObserver @@ -38,6 +39,7 @@ def __init__(self): ) IcecastTrackObserver.Options.args(self.__args) DabAudioCompanionTrackObserver.Options.args(self.__args) + SmcFtpTrackObserver.Options.args(self.__args) TickerTrackObserver.Options.args(self.__args) self.__args.add_argument( "-s", diff --git a/nowplaying/track/observers/dab_audio_companion.py b/nowplaying/track/observers/dab_audio_companion.py index 86bbeca2..ee6b5744 100644 --- a/nowplaying/track/observers/dab_audio_companion.py +++ b/nowplaying/track/observers/dab_audio_companion.py @@ -14,7 +14,7 @@ class DabAudioCompanionTrackObserver(TrackObserver): - """Update track metadata in a DAB+ tranmission through the 'Audio Companion' API.""" + """Update track data in a DAB+ transmission through the 'Audio Companion' API.""" name = "DAB+ Audio Companion" diff --git a/nowplaying/track/observers/smc_ftp.py b/nowplaying/track/observers/smc_ftp.py new file mode 100644 index 00000000..7fb1e0e8 --- /dev/null +++ b/nowplaying/track/observers/smc_ftp.py @@ -0,0 +1,102 @@ +import logging +from datetime import timedelta +from ftplib import FTP_TLS +from io import BytesIO + +import configargparse + +from ..track import Track +from .base import TrackObserver + +logger = logging.getLogger(__name__) + + +class SmcFtpTrackObserver(TrackObserver): + """Update track metadata for DLS and DL+ to the SMC FTP server.""" + + name = "SMC FTP" + + class Options(TrackObserver.Options): # pragma: no coverage + @classmethod + def args(cls, args: configargparse.ArgParser): + args.add_argument( + "--dab-smc", + help="Enable SMC FTP delivery", + type=bool, + default=False, + ) + args.add_argument( + "--dab-smc-ftp-hostname", + help="Hostname of SMC FTP server", + default=[], + ) + args.add_argument( + "--dab-smc-ftp-username", + help="Username for SMC FTP server", + ) + args.add_argument( + "--dab-smc-ftp-password", help="Password for SMC FTP server" + ) + + def __init__(self, hostname: str, username: str, password: str) -> None: + self.hostname: str = hostname + self.username: str = username + self.password: str = password + + def __init__(self, options: Options): + self._options = options + + def track_started(self, track: Track): + logger.info(f"Updating DAB+ DLS for track: {track.artist} - {track.title}") + + if track.get_duration() < timedelta(seconds=5): + logger.info("Track is less than 5 seconds, not sending to SMC") + return + + dls, dlplus = _dls_from_track(track) + + # check for too long meta and shorten to just artist + if dls.getbuffer().nbytes > 128: # pragma: no cover + logger.warning(f"SMC DLS to long {dls=}") + dls, dlplus = _dls_from_track(track, title=False) + + ftp = FTP_TLS() + ftp.connect(self._options.hostname) + ftp.sendcmd(f"USER {self._options.username}") + ftp.sendcmd(f"PASS {self._options.password}") + + ftp.storlines("STOR /dls/nowplaying.dls", dls) + ftp.storlines("STOR /dlplus/nowplaying.dls", dlplus) + + ftp.close() + + logger.info( + f"SMC FTP Server: {self._options.hostname} DLS: {dls} DL+: {dlplus}" + ) + + def track_finished(self, track): + return True + + +def _dls_from_track(track: Track, title=True) -> (BytesIO, BytesIO): + # track.artist contains station name if no artist is set + dls = f"{track.artist} - {track.show.name}" if title else track.artist + dlplus = "" + + if not track.has_default_title() and not track.has_default_artist(): + dls = f"{track.artist} - {track.title}" if title else track.artist + dlplus = ( + f"artist={track.artist}\ntitle={track.title}\n" + if title + else f"artist={track.artist}\n" + ) + + return (_bytes_from_string(dls), _bytes_from_string(dlplus)) + + +def _bytes_from_string(string: str) -> BytesIO: + b = BytesIO() + # encode as latin1 since that is what DAB supports + b.write(string.encode("latin1")) + b.seek(0) + return b diff --git a/tests/test_track_observer_smc_ftp.py b/tests/test_track_observer_smc_ftp.py new file mode 100644 index 00000000..e0e9cfb0 --- /dev/null +++ b/tests/test_track_observer_smc_ftp.py @@ -0,0 +1,92 @@ +"""Tests for :class:`SmcFtpTrackObserver`.""" +from unittest.mock import ANY, Mock, call, patch + +from nowplaying.track.observers.smc_ftp import SmcFtpTrackObserver +from nowplaying.track.track import Track + + +def test_init(): + """Test class:`SmcFrpTrackObserver`'s :meth:`.__init__` method.""" + SmcFtpTrackObserver( + options=SmcFtpTrackObserver.Options( + hostname="hostname", + username="username", + password="password", + ) + ) + + +@patch("nowplaying.track.observers.smc_ftp.FTP_TLS") +def test_track_started(mock_ftp, track_factory, show_factory): + """Test :class:`SmcFtpTrackObserver`'s :meth:`track_started` method.""" + mock_ftp_instance = Mock() + mock_ftp.return_value = mock_ftp_instance + + track = track_factory() + track.show = show_factory() + + smc_ftp_track_observer = SmcFtpTrackObserver( + options=SmcFtpTrackObserver.Options( + hostname="hostname", + username="username", + password="password", + ) + ) + smc_ftp_track_observer._ftp_cls = mock_ftp + smc_ftp_track_observer.track_started(track) + mock_ftp.assert_called_once() + mock_ftp_instance.assert_has_calls( + calls=[ + call.connect("hostname"), + call.sendcmd("USER username"), + call.sendcmd("PASS password"), + call.storlines( + "STOR /dls/nowplaying.dls", + ANY, + ), + call.storlines( + "STOR /dlplus/nowplaying.dls", + ANY, + ), + call.close(), + ] + ) + + # test skipping short tracks + track = track_factory(artist="Radio Bern", title="Livestream", duration=3) + mock_ftp.reset_mock() + mock_ftp_instance.reset_mock() + smc_ftp_track_observer.track_started(track) + mock_ftp_instance.storlines.assert_not_called() + + # test default track + track = track_factory(artist="Radio Bern", title="Livestream", duration=60) + track.show = show_factory() + mock_ftp.reset_mock() + mock_ftp_instance.reset_mock() + smc_ftp_track_observer.track_started(track) + mock_ftp_instance.assert_has_calls( + calls=[ + call.connect("hostname"), + call.sendcmd("USER username"), + call.sendcmd("PASS password"), + call.storlines( + "STOR /dls/nowplaying.dls", + ANY, + ), + call.storlines("STOR /dlplus/nowplaying.dls", ANY), + call.close(), + ] + ) + + +def test_track_finished(): + """Test class:`SmcFtpTrackObserver`'s :meth:`.track_finished` method.""" + smc_ftp_track_observer = SmcFtpTrackObserver( + options=SmcFtpTrackObserver.Options( + hostname="hostname", + username="username", + password="password", + ) + ) + assert smc_ftp_track_observer.track_finished(Track())