Skip to content

Commit

Permalink
feat(output): implement SMC FTP support
Browse files Browse the repository at this point in the history
  • Loading branch information
hairmare committed Dec 11, 2023
1 parent a199326 commit 664a226
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 1 deletion.
10 changes: 10 additions & 0 deletions nowplaying/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions nowplaying/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion nowplaying/track/observers/dab_audio_companion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
90 changes: 90 additions & 0 deletions nowplaying/track/observers/smc_ftp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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: str = ""
dlplus: str = ""

ftp = FTP_TLS()
ftp.connect(self._options.hostname)
ftp.sendcmd(f"USER {self._options.username}")
ftp.sendcmd(f"PASS {self._options.password}")

if not track.has_default_title() and not track.has_default_artist():
dls = f"{track.artist} - {track.title}"
dlplus = f"artist={track.artist}\ntitle={track.title}\n"
else:
logger.info("Track has default info, using show instead")
# track.artist contains station name if no artist is set
dls = f"{track.artist} - {track.show.name}"
dlplus = ""

ftp.storlines("STOR /dls/nowplaying.dls", _bytes_from_string(dls))
ftp.storlines("STOR /dlplus/nowplaying.dls", _bytes_from_string(dlplus))
ftp.close()

logger.info(
f"SMC FTP Server: {self._options.hostname} DLS: {dls} DL+: {dlplus}"
)

def track_finished(self, track):
return True


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
92 changes: 92 additions & 0 deletions tests/test_track_observer_smc_ftp.py
Original file line number Diff line number Diff line change
@@ -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())

0 comments on commit 664a226

Please sign in to comment.