Skip to content

Commit e185ecf

Browse files
committed
wip: Support streaming from video services
Relates to #2186
1 parent 6704359 commit e185ecf

File tree

7 files changed

+304
-183
lines changed

7 files changed

+304
-183
lines changed

docs/api/pyatv.exceptions.html

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ <h4><code><a title="pyatv.exceptions.InvalidCredentialsError" href="#pyatv.excep
5454
<h4><code><a title="pyatv.exceptions.InvalidDmapDataError" href="#pyatv.exceptions.InvalidDmapDataError">InvalidDmapDataError</a></code></h4>
5555
</li>
5656
<li>
57+
<h4><code><a title="pyatv.exceptions.InvalidFormatError" href="#pyatv.exceptions.InvalidFormatError">InvalidFormatError</a></code></h4>
58+
</li>
59+
<li>
5760
<h4><code><a title="pyatv.exceptions.InvalidResponseError" href="#pyatv.exceptions.InvalidResponseError">InvalidResponseError</a></code></h4>
5861
</li>
5962
<li>
@@ -108,7 +111,7 @@ <h1 class="title">Module <code>pyatv.exceptions</code></h1>
108111
</header>
109112
<section id="section-intro">
110113
<p>Local exceptions used by library.</p>
111-
<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>
114+
<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>
112115
</section>
113116
<section>
114117
</section>
@@ -275,6 +278,19 @@ <h3>Ancestors</h3>
275278
<li>builtins.BaseException</li>
276279
</ul>
277280
</dd>
281+
<dt id="pyatv.exceptions.InvalidFormatError"><code class="flex name class">
282+
<span>class <span class="ident">InvalidFormatError</span></span>
283+
<span>(</span><span>*args, **kwargs)</span>
284+
</code></dt>
285+
<dd>
286+
<section class="desc"><p>Raised when an unsupported (file) format is encountered.</p></section>
287+
<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>
288+
<h3>Ancestors</h3>
289+
<ul class="hlist">
290+
<li>builtins.Exception</li>
291+
<li>builtins.BaseException</li>
292+
</ul>
293+
</dd>
278294
<dt id="pyatv.exceptions.InvalidResponseError"><code class="flex name class">
279295
<span>class <span class="ident">InvalidResponseError</span></span>
280296
<span>(</span><span>*args, **kwargs)</span>

docs/api/pyatv.interface.html

Lines changed: 198 additions & 180 deletions
Large diffs are not rendered by default.

pyatv/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,7 @@ class OperationTimeoutError(Exception):
129129

130130
class SettingsError(Exception):
131131
"""Raised when an error related to settings happens."""
132+
133+
134+
class InvalidFormatError(Exception):
135+
"""Raised when an unsupported (file) format is encountered."""

pyatv/interface.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from pyatv.support.device_info import lookup_version
4444
from pyatv.support.http import ClientSessionManager
4545
from pyatv.support.state_producer import StateProducer
46+
from pyatv.support.yt_dlp import extract_video_url
4647

4748
__pdoc__ = {
4849
"feature": False,
@@ -874,6 +875,24 @@ async def stream_file(
874875
"""
875876
raise exceptions.NotSupportedError()
876877

878+
async def play_service(self, video_url: str) -> None:
879+
"""Play video from a video service, e.g. YouTube.
880+
881+
This method will try to extract the underlying video URL from various video
882+
hosting services, e.g. YouTube, and play the video using play_url.
883+
884+
Note 1: For this method to work, yt-dlp must be installed. A NotSupportedError
885+
is thrown otherwise.
886+
887+
Note 2: By default, pyatv will try to play the video with highest bitrate. It's
888+
not possible to possible to change this at the moment, but will be in the
889+
future.
890+
891+
INCUBATING METHOD - MIGHT CHANGE IN THE FUTURE!
892+
"""
893+
url = await extract_video_url(video_url)
894+
await self.play_url(url)
895+
877896

878897
class DeviceListener(ABC):
879898
"""Listener interface for generic device updates."""

pyatv/protocols/airplay/player.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
_LOGGER = logging.getLogger(__name__)
1313

1414
PLAY_RETRIES = 3
15-
WAIT_RETRIES = 5
15+
WAIT_RETRIES = 10
16+
1617
HEADERS = {
1718
"User-Agent": "AirPlay/550.10",
1819
"Content-Type": "application/x-apple-binary-plist",

pyatv/support/yt_dlp.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Helper methods for working with yt-dlp.
2+
3+
Currently ytp-dl is used to extract video URLs from various video sites, e.g. YouTube
4+
so they can be streamed via AirPlay.
5+
"""
6+
import asyncio
7+
8+
from pyatv import exceptions
9+
10+
11+
def _extract_video_url(video_link: str) -> str:
12+
# TODO: For now, dynamic support for this feature. User must manually install
13+
# yt-dlp, it will not be pulled in by pyatv.
14+
try:
15+
import yt_dlp # pylint: disable=import-outside-toplevel
16+
except ModuleNotFoundError as ex:
17+
raise exceptions.NotSupportedError("package yt-dlp not installed") from ex
18+
19+
with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) as ydl:
20+
info = ydl.sanitize_info(ydl.extract_info(video_link, download=False))
21+
22+
if "formats" not in info:
23+
raise exceptions.NotSupportedError(
24+
"formats are missing, maybe authentication is needed (not supported)?"
25+
)
26+
27+
best = None
28+
best_bitrate = 0
29+
30+
# Try to find supported video stream with highest bitrate. No way to customize
31+
# this in any way for now.
32+
for video_format in [
33+
x for x in info["formats"] if x.get("protocol") == "m3u8_native"
34+
]:
35+
if video_format["video_ext"] == "none":
36+
continue
37+
if video_format["has_drm"]:
38+
continue
39+
40+
if video_format["vbr"] > best_bitrate:
41+
best = video_format
42+
best_bitrate = video_format["vbr"]
43+
44+
if not best or "manifest_url" not in best:
45+
raise exceptions.NotSupportedError("manifest url could not be extracted")
46+
47+
return best["manifest_url"]
48+
49+
50+
async def extract_video_url(video_link: str) -> str:
51+
"""Extract video URL from external video service link.
52+
53+
This method takes a video link from a video service, e.g. YouTube, and extracts the
54+
underlying video URL that (hopefully) can be played via AirPlay. Currently yt-dlp
55+
is used to the extract the URL, thus all services supported by yt-dlp should be
56+
supported. No customization (e.g. resolution) nor authorization is supported at the
57+
moment, putting some restrictions on use case.
58+
"""
59+
loop = asyncio.get_event_loop()
60+
try:
61+
return await loop.run_in_executor(None, _extract_video_url, video_link)
62+
except Exception as ex:
63+
raise exceptions.InvalidFormatError(f"video {video_link} not supported") from ex

tests/core/test_facade.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -967,7 +967,7 @@ async def test_base_methods_guarded_after_close(facade_dummy, register_interface
967967
(RemoteControl, "remote_control", {}),
968968
(Metadata, "metadata", {}),
969969
(PushUpdater, "push_updater", {}),
970-
(Stream, "stream", {}),
970+
(Stream, "stream", {"play_service"}),
971971
(Power, "power", {}),
972972
# in_states is not abstract but uses get_features, will which will raise
973973
(Features, "features", {"in_state"}),

0 commit comments

Comments
 (0)