Skip to content

Commit

Permalink
Extract AirPlay code to separate interface
Browse files Browse the repository at this point in the history
AirPlay will play a bigger part in the future, e.g. with device
authentication. Adding new features in a good way with current API is
pretty tough, so this change aims to make that easier by extracting
AirPlay to a separate public interface (similar to remote_control or
playing).

THIS IS A BREAKING CHANGE! The play_url has moved from remote_control
to airplay and all users must update their code.
  • Loading branch information
postlund committed Jun 7, 2017
1 parent b6b4f3e commit e2387fe
Show file tree
Hide file tree
Showing 8 changed files with 85 additions and 46 deletions.
8 changes: 1 addition & 7 deletions pyatv/__init__.py
Expand Up @@ -9,9 +9,7 @@
from zeroconf import ServiceBrowser, Zeroconf
from aiohttp import ClientSession

from pyatv.airplay import AirPlay
from pyatv.pairing import PairingHandler
from pyatv.daap import (DaapSession, DaapRequester)
from pyatv.internal.apple_tv import AppleTVInternal

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -116,11 +114,7 @@ def connect_to_apple_tv(details, loop, session=None):

# If/when needed, the library should figure out the correct type of Apple
# TV and return the correct type for it.
airplay = AirPlay(loop, session, details.address)
daap_session = DaapSession(session)
requester = DaapRequester(
daap_session, details.address, details.login_id, details.port)
return AppleTVInternal(loop, session, requester, airplay)
return AppleTVInternal(loop, session, details)


def pair_with_apple_tv(loop, pin_code, name, pairing_guid=None):
Expand Down
7 changes: 7 additions & 0 deletions pyatv/__main__.py
Expand Up @@ -227,12 +227,15 @@ def _handle_commands(args, loop):
return 0


# pylint: disable=too-many-return-statements
@asyncio.coroutine
def _handle_command(args, cmd, atv, loop):
# TODO: Add these to array and use a loop
playing_resp = yield from atv.metadata.playing()
ctrl = retrieve_commands(atv.remote_control, developer=args.developer)
metadata = retrieve_commands(atv.metadata, developer=args.developer)
playing = retrieve_commands(playing_resp, developer=args.developer)
airplay = retrieve_commands(atv.airplay, developer=args.developer)
other = {'push_updates': 'Listen for push updates'}

# Parse input command and argument from user
Expand All @@ -241,6 +244,7 @@ def _handle_command(args, cmd, atv, loop):
_print_commands('Remote control', ctrl)
_print_commands('Metadata', metadata)
_print_commands('Playing', playing)
_print_commands('AirPlay', airplay)
_print_commands('Other', other, newline=False)

elif cmd == 'artwork':
Expand Down Expand Up @@ -268,6 +272,9 @@ def _handle_command(args, cmd, atv, loop):
elif cmd in playing:
return (yield from _exec_command(playing_resp, cmd, *cmd_args))

elif cmd in airplay:
return (yield from _exec_command(atv.airplay, cmd, *cmd_args))

else:
logging.error('Unknown command: %s', args.command[0])
return 1
Expand Down
1 change: 1 addition & 0 deletions pyatv/airplay/__init__.py
@@ -0,0 +1 @@
"""AirPlay related functionality."""
6 changes: 3 additions & 3 deletions pyatv/airplay.py → pyatv/airplay/player.py
Expand Up @@ -15,7 +15,7 @@


# pylint: disable=too-few-public-methods
class AirPlay:
class AirPlayPlayer:
"""This class helps with playing media from an URL."""

def __init__(self, loop, session, address):
Expand All @@ -25,12 +25,12 @@ def __init__(self, loop, session, address):
self.session = session

@asyncio.coroutine
def play_url(self, url, start_position, port=AIRPLAY_PORT):
def play_url(self, url, position=0, port=AIRPLAY_PORT):
"""Play media from an URL on the device."""
headers = {'User-Agent': 'MediaControl/1.0',
'Content-Type': 'text/parameters'}
body = "Content-Location: {}\nStart-Position: {}\n\n".format(
url, start_position)
url, position)

address = self._url(port, 'play')
_LOGGER.debug('AirPlay %s to %s', url, address)
Expand Down
21 changes: 16 additions & 5 deletions pyatv/interface.py
Expand Up @@ -122,11 +122,6 @@ def set_repeat(self, repeat_mode):
"""Change repeat mode."""
raise exceptions.NotSupportedError

@abstractmethod
def play_url(self, url, start_position=0, port=7000):
"""Play media from an URL on the device."""
raise exceptions.NotSupportedError


class Playing(object):
"""Base class for retrieving what is currently playing."""
Expand Down Expand Up @@ -278,6 +273,17 @@ def stop(self):
raise exceptions.NotSupportedError


class AirPlay(object):
"""Base class for AirPlay functionality."""

__metaclass__ = ABCMeta

@abstractmethod
def play_url(self, url, **kwargs):
"""Play media from an URL on the device."""
raise exceptions.NotSupportedError


class AppleTV(object):
"""Base class representing an Apple TV."""

Expand Down Expand Up @@ -313,3 +319,8 @@ def metadata(self):
def push_updater(self):
"""Return API for handling push update from the Apple TV."""
raise exceptions.NotSupportedError

@abstractproperty
def airplay(self):
"""Return API for working with AirPlay."""
raise exceptions.NotSupportedError
73 changes: 49 additions & 24 deletions pyatv/internal/apple_tv.py
Expand Up @@ -9,8 +9,10 @@
import asyncio

from pyatv import (const, exceptions, dmap, tags, convert)
from pyatv.airplay import player
from pyatv.daap import (DaapSession, DaapRequester)
from pyatv.interface import (AppleTV, RemoteControl, Metadata,
Playing, PushUpdater)
Playing, PushUpdater, AirPlay)


_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -93,11 +95,10 @@ def set_property(self, prop, value):
class RemoteControlInternal(RemoteControl):
"""Implementation of API for controlling an Apple TV."""

def __init__(self, apple_tv, airplay):
def __init__(self, apple_tv):
"""Initialize remote control instance."""
super().__init__()
self.apple_tv = apple_tv
self.airplay = airplay

@asyncio.coroutine
def up(self):
Expand Down Expand Up @@ -204,15 +205,6 @@ def set_repeat(self, repeat_mode):
"""Change repeat mode."""
return self.apple_tv.set_property('dacp.repeatstate', repeat_mode)

def play_url(self, url, start_position=0, port=7000):
"""Play media from an URL on the device.
Note: This method will not yield until the media has finished playing.
The Apple TV requires the request to stay open during the entire
play duration.
"""
return self.airplay.play_url(url, int(start_position), port)


class PlayingInternal(Playing):
"""Implementation of API for retrieving what is playing."""
Expand Down Expand Up @@ -412,45 +404,78 @@ def _poller(self, initial_delay):
self._future = None


# pylint: disable=too-few-public-methods
class AirPlayInternal(AirPlay):
"""Implementation of API for AirPlay support."""

def __init__(self, airplay_player):
"""Initialize a new AirPlayInternal instance."""
self.player = airplay_player

def play_url(self, url, **kwargs):
"""Play media from an URL on the device.
Note: This method will not yield until the media has finished playing.
The Apple TV requires the request to stay open during the entire
play duration.
"""
start_position = 0 if 'position' not in kwargs else kwargs['position']
port = 7000 if 'port' not in kwargs else kwargs['port']
return self.player.play_url(url, int(start_position), port)


class AppleTVInternal(AppleTV):
"""Implementation of API support for Apple TV."""

def __init__(self, loop, session, requester, airplay):
# This is a container class so it's OK with many attributes
# pylint: disable=too-many-instance-attributes
def __init__(self, loop, session, details):
"""Initialize a new Apple TV."""
super().__init__()
self.session = session
self.requester = requester
self.apple_tv = BaseAppleTV(self.requester)
self.atv_remote = RemoteControlInternal(self.apple_tv, airplay)
self.atv_metadata = MetadataInternal(self.apple_tv)
self.atv_push_updater = PushUpdaterInternal(loop, self.apple_tv)
self._session = session
self._daap = DaapSession(session)
self._requester = DaapRequester(
self._daap, details.address, details.login_id, details.port)

self._apple_tv = BaseAppleTV(self._requester)
self._atv_remote = RemoteControlInternal(self._apple_tv)
self._atv_metadata = MetadataInternal(self._apple_tv)
self._atv_push_updater = PushUpdaterInternal(loop, self._apple_tv)

airplay_player = player.AirPlayPlayer(loop, session, details.address)
self._airplay = AirPlayInternal(airplay_player)

def login(self):
"""Perform an explicit login.
Not needed as login is performed automatically.
"""
return self.requester.login()
return self._requester.login()

@asyncio.coroutine
def logout(self):
"""Perform an explicit logout.
Must be done when session is no longer needed to not leak resources.
"""
self.session.close()
self._session.close()

@property
def remote_control(self):
"""Return API for controlling the Apple TV."""
return self.atv_remote
return self._atv_remote

@property
def metadata(self):
"""Return API for retrieving metadata from Apple TV."""
return self.atv_metadata
return self._atv_metadata

@property
def push_updater(self):
"""Return API for handling push update from the Apple TV."""
return self.atv_push_updater
return self._atv_push_updater

@property
def airplay(self):
"""Return API for working with AirPlay."""
return self._airplay
11 changes: 6 additions & 5 deletions tests/test_airplay.py
Expand Up @@ -6,15 +6,15 @@
from aiohttp import ClientSession
from aiohttp.test_utils import (AioHTTPTestCase, unittest_run_loop)

from pyatv import airplay
from pyatv.airplay import player
from tests.fake_apple_tv import (FakeAppleTV, AppleTVUseCases)


STREAM = 'http://airplaystream'
START_POSITION = 0.8


class AirPlayTest(AioHTTPTestCase):
class AirPlayPlayerTest(AioHTTPTestCase):

def setUp(self):
AioHTTPTestCase.setUp(self)
Expand All @@ -23,7 +23,7 @@ def setUp(self):
# This is a hack that overrides asyncio.sleep to avoid making the test
# slow. It also counts number of calls, since this is quite important
# to the general function.
airplay.asyncio.sleep = self.fake_asyncio_sleep
player.asyncio.sleep = self.fake_asyncio_sleep
self.no_of_sleeps = 0

def tearDown(self):
Expand Down Expand Up @@ -54,8 +54,9 @@ def test_play_video(self):
self.usecase.airplay_playback_idle()

session = ClientSession(loop=self.loop)
aplay = airplay.AirPlay(self.loop, session, '127.0.0.1')
yield from aplay.play_url(STREAM, START_POSITION, self.app.port)
aplay = player.AirPlayPlayer(self.loop, session, '127.0.0.1')
yield from aplay.play_url(
STREAM, position=START_POSITION, port=self.app.port)

self.assertEqual(self.fake_atv.last_airplay_url, STREAM)
self.assertEqual(self.fake_atv.last_airplay_start, START_POSITION)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_functional.py
Expand Up @@ -95,8 +95,8 @@ def test_play_url(self):
self.usecase.airplay_playback_playing()
self.usecase.airplay_playback_idle()

yield from self.atv.remote_control.play_url(
AIRPLAY_STREAM, 0, port=self.app.port)
yield from self.atv.airplay.play_url(
AIRPLAY_STREAM, port=self.app.port)

self.assertEqual(self.fake_atv.last_airplay_url, AIRPLAY_STREAM)

Expand Down

0 comments on commit e2387fe

Please sign in to comment.