Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wip: Support streaming from video services #2187

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/api/pyatv.exceptions.html
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ <h4><code><a title="pyatv.exceptions.InvalidCredentialsError" href="#pyatv.excep
<h4><code><a title="pyatv.exceptions.InvalidDmapDataError" href="#pyatv.exceptions.InvalidDmapDataError">InvalidDmapDataError</a></code></h4>
</li>
<li>
<h4><code><a title="pyatv.exceptions.InvalidFormatError" href="#pyatv.exceptions.InvalidFormatError">InvalidFormatError</a></code></h4>
</li>
<li>
<h4><code><a title="pyatv.exceptions.InvalidResponseError" href="#pyatv.exceptions.InvalidResponseError">InvalidResponseError</a></code></h4>
</li>
<li>
Expand Down Expand Up @@ -108,7 +111,7 @@ <h1 class="title">Module <code>pyatv.exceptions</code></h1>
</header>
<section id="section-intro">
<p>Local exceptions used by library.</p>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L1-L131" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L1-L135" class="git-link">Browse git</a></div>
</section>
<section>
</section>
Expand Down Expand Up @@ -275,6 +278,19 @@ <h3>Ancestors</h3>
<li>builtins.BaseException</li>
</ul>
</dd>
<dt id="pyatv.exceptions.InvalidFormatError"><code class="flex name class">
<span>class <span class="ident">InvalidFormatError</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<section class="desc"><p>Raised when an unsupported (file) format is encountered.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L134-L135" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>builtins.Exception</li>
<li>builtins.BaseException</li>
</ul>
</dd>
<dt id="pyatv.exceptions.InvalidResponseError"><code class="flex name class">
<span>class <span class="ident">InvalidResponseError</span></span>
<span>(</span><span>*args, **kwargs)</span>
Expand Down
378 changes: 198 additions & 180 deletions docs/api/pyatv.interface.html

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions pyatv/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,7 @@ class OperationTimeoutError(Exception):

class SettingsError(Exception):
"""Raised when an error related to settings happens."""


class InvalidFormatError(Exception):
"""Raised when an unsupported (file) format is encountered."""
19 changes: 19 additions & 0 deletions pyatv/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from pyatv.support.device_info import lookup_version
from pyatv.support.http import ClientSessionManager
from pyatv.support.state_producer import StateProducer
from pyatv.support.yt_dlp import extract_video_url

__pdoc__ = {
"feature": False,
Expand Down Expand Up @@ -874,6 +875,24 @@
"""
raise exceptions.NotSupportedError()

async def play_service(self, video_url: str) -> None:
"""Play video from a video service, e.g. YouTube.

This method will try to extract the underlying video URL from various video
hosting services, e.g. YouTube, and play the video using play_url.

Note 1: For this method to work, yt-dlp must be installed. A NotSupportedError
is thrown otherwise.

Note 2: By default, pyatv will try to play the video with highest bitrate. It's
not possible to possible to change this at the moment, but will be in the
future.

INCUBATING METHOD - MIGHT CHANGE IN THE FUTURE!
"""
url = await extract_video_url(video_url)
await self.play_url(url)

Check warning on line 894 in pyatv/interface.py

View check run for this annotation

Codecov / codecov/patch

pyatv/interface.py#L893-L894

Added lines #L893 - L894 were not covered by tests


class DeviceListener(ABC):
"""Listener interface for generic device updates."""
Expand Down
3 changes: 2 additions & 1 deletion pyatv/protocols/airplay/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
_LOGGER = logging.getLogger(__name__)

PLAY_RETRIES = 3
WAIT_RETRIES = 5
WAIT_RETRIES = 10

HEADERS = {
"User-Agent": "AirPlay/550.10",
"Content-Type": "application/x-apple-binary-plist",
Expand Down
63 changes: 63 additions & 0 deletions pyatv/support/yt_dlp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Helper methods for working with yt-dlp.

Currently ytp-dl is used to extract video URLs from various video sites, e.g. YouTube
so they can be streamed via AirPlay.
"""
import asyncio

from pyatv import exceptions


def _extract_video_url(video_link: str) -> str:
# TODO: For now, dynamic support for this feature. User must manually install
# yt-dlp, it will not be pulled in by pyatv.
try:
import yt_dlp # pylint: disable=import-outside-toplevel
except ModuleNotFoundError as ex:

Check warning on line 16 in pyatv/support/yt_dlp.py

View check run for this annotation

Codecov / codecov/patch

pyatv/support/yt_dlp.py#L14-L16

Added lines #L14 - L16 were not covered by tests
raise exceptions.NotSupportedError("package yt-dlp not installed") from ex

with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) as ydl:
info = ydl.sanitize_info(ydl.extract_info(video_link, download=False))

Check warning on line 20 in pyatv/support/yt_dlp.py

View check run for this annotation

Codecov / codecov/patch

pyatv/support/yt_dlp.py#L19-L20

Added lines #L19 - L20 were not covered by tests

if "formats" not in info:

Check warning on line 22 in pyatv/support/yt_dlp.py

View check run for this annotation

Codecov / codecov/patch

pyatv/support/yt_dlp.py#L22

Added line #L22 was not covered by tests
raise exceptions.NotSupportedError(
"formats are missing, maybe authentication is needed (not supported)?"
)

best = None
best_bitrate = 0

Check warning on line 28 in pyatv/support/yt_dlp.py

View check run for this annotation

Codecov / codecov/patch

pyatv/support/yt_dlp.py#L27-L28

Added lines #L27 - L28 were not covered by tests

# Try to find supported video stream with highest bitrate. No way to customize
# this in any way for now.
for video_format in [

Check warning on line 32 in pyatv/support/yt_dlp.py

View check run for this annotation

Codecov / codecov/patch

pyatv/support/yt_dlp.py#L32

Added line #L32 was not covered by tests
x for x in info["formats"] if x.get("protocol") == "m3u8_native"
]:
if video_format["video_ext"] == "none":
continue
if video_format["has_drm"]:
continue

Check warning on line 38 in pyatv/support/yt_dlp.py

View check run for this annotation

Codecov / codecov/patch

pyatv/support/yt_dlp.py#L35-L38

Added lines #L35 - L38 were not covered by tests

if video_format["vbr"] > best_bitrate:
best = video_format
best_bitrate = video_format["vbr"]

Check warning on line 42 in pyatv/support/yt_dlp.py

View check run for this annotation

Codecov / codecov/patch

pyatv/support/yt_dlp.py#L40-L42

Added lines #L40 - L42 were not covered by tests

if not best or "manifest_url" not in best:

Check warning on line 44 in pyatv/support/yt_dlp.py

View check run for this annotation

Codecov / codecov/patch

pyatv/support/yt_dlp.py#L44

Added line #L44 was not covered by tests
raise exceptions.NotSupportedError("manifest url could not be extracted")

return best["manifest_url"]

Check warning on line 47 in pyatv/support/yt_dlp.py

View check run for this annotation

Codecov / codecov/patch

pyatv/support/yt_dlp.py#L47

Added line #L47 was not covered by tests


async def extract_video_url(video_link: str) -> str:
"""Extract video URL from external video service link.

This method takes a video link from a video service, e.g. YouTube, and extracts the
underlying video URL that (hopefully) can be played via AirPlay. Currently yt-dlp
is used to the extract the URL, thus all services supported by yt-dlp should be
supported. No customization (e.g. resolution) nor authorization is supported at the
moment, putting some restrictions on use case.
"""
loop = asyncio.get_event_loop()
try:
return await loop.run_in_executor(None, _extract_video_url, video_link)
except Exception as ex:
raise exceptions.InvalidFormatError(f"video {video_link} not supported") from ex

Check warning on line 63 in pyatv/support/yt_dlp.py

View check run for this annotation

Codecov / codecov/patch

pyatv/support/yt_dlp.py#L59-L63

Added lines #L59 - L63 were not covered by tests
2 changes: 1 addition & 1 deletion tests/core/test_facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,7 @@ async def test_base_methods_guarded_after_close(facade_dummy, register_interface
(RemoteControl, "remote_control", {}),
(Metadata, "metadata", {}),
(PushUpdater, "push_updater", {}),
(Stream, "stream", {}),
(Stream, "stream", {"play_service"}),
(Power, "power", {}),
# in_states is not abstract but uses get_features, will which will raise
(Features, "features", {"in_state"}),
Expand Down
Loading