Skip to content
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 @@ async def stream_file(
"""
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)


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:
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))

if "formats" not in info:
raise exceptions.NotSupportedError(
"formats are missing, maybe authentication is needed (not supported)?"
)

best = None
best_bitrate = 0

# Try to find supported video stream with highest bitrate. No way to customize
# this in any way for now.
for video_format in [
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

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

if not best or "manifest_url" not in best:
raise exceptions.NotSupportedError("manifest url could not be extracted")

return best["manifest_url"]


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
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