Skip to content

Commit 6ca4a33

Browse files
committed
Refactor for new CT API
1 parent b1f0431 commit 6ca4a33

File tree

13 files changed

+247
-435
lines changed

13 files changed

+247
-435
lines changed

requirements.txt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,2 @@
1-
docopt
2-
lxml
3-
m3u8>=0.3
1+
docopt-ng
42
requests

setup.cfg

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,5 @@ convention = google
1313
[mypy]
1414
check_untyped_defs = True
1515

16-
[mypy-docopt.*]
17-
ignore_missing_imports = True
18-
19-
[mypy-lxml.*]
20-
ignore_missing_imports = True
21-
22-
[mypy-m3u8.*]
23-
ignore_missing_imports = True
24-
25-
[mypy-responses.*]
26-
ignore_missing_imports = True
16+
[mypy-testfixtures.*]
17+
follow_untyped_imports = True

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
REQUIREMENTS = open('requirements.txt').read().split()
66
EXTRAS_REQUIRE = {
77
'quality': ('flake8', 'isort', 'bandit', 'mypy', 'pydocstyle'),
8-
'tests': ('responses', ),
8+
'tests': ('responses', 'testfixtures'),
9+
'types': ('types-requests', ),
910
}
1011
LONG_DESCRIPTION = open('README.rst').read() + '\n\n' + open('Changelog.rst').read()
1112
CLASSIFIERS = [

televize.py

Lines changed: 120 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,21 @@
11
#!/usr/bin/env python3
22
"""Play Czech television stream in custom player.
33
4-
Usage: televize.py [options] live <channel>
4+
Usage: televize.py [options] channels
5+
televize.py [options] live <channel>
56
televize.py [options] ivysilani <url>
67
televize.py -h | --help
78
televize.py --version
89
910
Subcommands:
11+
channels print a list of available channels
1012
live play live channel
1113
ivysilani play video from ivysilani archive
1214
13-
Live channels:
14-
1 CT1
15-
2 CT2
16-
24 CT24
17-
sport CTsport
18-
D CT:D
19-
art CTart
20-
2115
Options:
2216
-h, --help show this help message and exit
2317
--version show program's version number and exit
24-
-q, --quality=QUAL select stream quality [default: min]. Possible values are integers, 'min' and 'max'.
18+
-q, --quality=QUAL select stream quality [default: 540p]. Commonly available are 180p, 360p, 540p, 720p and 1080p.
2519
-p, --player=PLAYER player command [default: mpv]
2620
-d, --debug print debug messages
2721
"""
@@ -30,156 +24,153 @@
3024
import shlex
3125
import subprocess # nosec
3226
import sys
33-
from collections import OrderedDict
27+
from dataclasses import dataclass
28+
from typing import Any, Dict, Iterable, Optional
3429
from urllib.parse import urljoin, urlsplit
3530

36-
import m3u8
3731
import requests
3832
from docopt import docopt
39-
from lxml import etree # nosec
4033

4134
__version__ = '0.6.0'
4235

4336

44-
PLAYLIST_LINK = 'https://www.ceskatelevize.cz/ivysilani/ajax/get-client-playlist/'
45-
CHANNEL_NAMES = OrderedDict((
46-
('1', 1),
47-
('2', 2),
48-
('24', 24),
49-
('sport', 4),
50-
('D', 5),
51-
('art', 6),
52-
))
53-
PLAYLIST_TYPE_CHANNEL = 'channel'
54-
PLAYLIST_TYPE_EPISODE = 'episode'
37+
################################################################################
38+
# Channels API
5539

56-
PORADY_PATH_PATTERN = re.compile(r'^/porady/[^/]+/(?P<playlist_id>\d+)(-[^/]*)?/?$')
40+
CHANNELS_LINK = "https://ct24.ceskatelevize.cz/api/live"
5741

5842

59-
################################################################################
60-
# Playlist functions
61-
def parse_quality(value: str) -> int:
62-
"""Return quality selector parsed from user input value.
43+
@dataclass
44+
class Channel:
45+
"""Represents a TV channel.
6346
64-
@raises ValueError: If value is not a valid quality selector.
47+
Attributes:
48+
id: Channel ID
49+
name: Channel name
50+
slug: Channel slug
51+
title: Current or next programme title
6552
"""
66-
# Special keywords
67-
if value == 'min':
68-
return 0
69-
elif value == 'max':
70-
return -1
71-
try:
72-
return int(value)
73-
except ValueError:
74-
raise ValueError("Quality '{}' is not a valid value.".format(value))
53+
id: str
54+
name: str
55+
slug: str
56+
title: Optional[str] = None
57+
58+
59+
def get_channels() -> Iterable[Channel]:
60+
"""Iterate over available channels."""
61+
response = requests.get(CHANNELS_LINK, timeout=10)
62+
logging.debug("Channels response[%s]: %s", response.status_code, response.text)
63+
response.raise_for_status()
64+
for channel_data in response.json()["data"]:
65+
if not channel_data.get("__typename") == "LiveBroadcast":
66+
# Skip non-live channels
67+
continue
68+
if current := channel_data.get('current'):
69+
name = current['channelSettings']['channelName']
70+
slug = current['slug']
71+
title = current['title']
72+
elif next := channel_data.get('next'):
73+
name = next['channelSettings']['channelName']
74+
slug = next['slug']
75+
title = next['title']
76+
else:
77+
name = ''
78+
slug = ''
79+
title = None
7580

81+
yield Channel(id=channel_data['id'], name=name, slug=slug, title=title)
7682

77-
def get_playlist(playlist_id, playlist_type, quality: int):
78-
"""Extract the playlist for CT video.
7983

80-
@param playlist_id: ID of playlist
81-
@param playlist_type: Type of playlist
82-
@param quality: Quality selector
83-
"""
84-
assert playlist_type in (PLAYLIST_TYPE_CHANNEL, PLAYLIST_TYPE_EPISODE) # nosec
85-
# First get the custom client playlist URL
86-
post_data = {
87-
'playlist[0][id]': playlist_id,
88-
'playlist[0][type]': playlist_type,
89-
'requestUrl': '/ivysilani/',
90-
'requestSource': "iVysilani",
91-
'addCommercials': 0,
92-
'type': "html"
93-
}
94-
response = requests.post(PLAYLIST_LINK, post_data, headers={'x-addr': '127.0.0.1'})
95-
logging.debug("Client playlist: %s", response.text)
96-
client_playlist = response.json()
97-
98-
# Get the custom playlist URL to get playlist JSON meta data (including playlist URL)
99-
response = requests.get(urljoin(PLAYLIST_LINK, client_playlist["url"]))
100-
logging.debug("Playlist URL: %s", response.text)
101-
playlist_metadata = response.json()
102-
stream_playlist_url = playlist_metadata['playlist'][0]['streamUrls']['main']
103-
104-
# Use playlist URL to get the M3U playlist with streams
105-
response = requests.get(urljoin(PLAYLIST_LINK, stream_playlist_url))
106-
logging.debug("Variant playlist: %s", response.text)
107-
playlist_base_url = response.url
108-
variant_playlist = m3u8.loads(response.text)
109-
110-
# Select stream based on quality
111-
playlists = sorted(variant_playlist.playlists, key=lambda p: p.stream_info.bandwidth)
112-
try:
113-
playlist = playlists[quality]
114-
except IndexError:
115-
raise ValueError("Requested quality {} is not available.".format(quality))
116-
playlist.base_uri = playlist_base_url
117-
return playlist
84+
def print_channels(channels: Iterable[Channel]) -> None:
85+
"""List available channels."""
86+
for channel in channels:
87+
print(f"{channel.slug}: {channel.name} - {channel.title}")
11888

11989

120-
def get_ivysilani_playlist(url, quality: int):
121-
"""Extract the playlist for ivysilani page.
90+
################################################################################
91+
# Playlist functions
92+
LIVE_PLAYLIST_LINK = "https://api.ceskatelevize.cz/video/v1/playlist-live/v1/stream-data/channel/"
93+
12294

123-
@param url: URL of the web page
124-
@param quality: Quality selector
95+
def get_live_playlist(channel: str, quality: str) -> str:
96+
"""Return playlist URL for live CT channel.
97+
98+
@param channel: Channel slug
99+
@param quality: Requested quality
125100
"""
126-
# Porady pages have playlist ID in URL
127-
split = urlsplit(url)
128-
match = PORADY_PATH_PATTERN.match(split.path)
129-
if match:
130-
playlist_id = match.group('playlist_id')
131-
return get_playlist(playlist_id, PLAYLIST_TYPE_EPISODE, quality)
101+
channels = {c.slug: c for c in get_channels()}
102+
if channel not in channels:
103+
raise ValueError(f"Channel {channel} not found.")
104+
channel_obj = channels[channel]
105+
106+
data = {'quality': quality}
107+
response = requests.get(urljoin(LIVE_PLAYLIST_LINK, channel_obj.id), data, timeout=10)
108+
logging.debug("Live playlist response[%s]: %s", response.status_code, response.text)
109+
response.raise_for_status()
132110

133-
# Try ivysilani URL
134-
response = requests.get(url)
135-
page = etree.HTML(response.text)
136-
play_button = page.find('.//a[@class="programmeToPlaylist"]')
137-
if play_button is None:
138-
raise ValueError("Can't find playlist on the ivysilani page.")
139-
item = play_button.get('rel')
140-
if not item:
141-
raise ValueError("Can't find playlist on the ivysilani page.")
142-
return get_playlist(item, PLAYLIST_TYPE_EPISODE, quality)
111+
playlist_data = response.json()
112+
return playlist_data["streamUrls"]["main"]
143113

144114

145-
def get_live_playlist(channel, quality: int):
146-
"""Extract the playlist for live CT channel.
115+
IVYSILANI_PLAYLIST_LINK = "https://api.ceskatelevize.cz/video/v1/playlist-vod/v1/stream-data/media/external/"
147116

148-
@param channel: Name of the channel
149-
@param quality: Quality selector
117+
118+
def get_ivysilani_playlist(program_id: str, quality: str) -> str:
119+
"""Return playlist URL for ivysilani.
120+
121+
@param program_id: Program ID
122+
@param quality: Requested quality
150123
"""
151-
return get_playlist(CHANNEL_NAMES[channel], PLAYLIST_TYPE_CHANNEL, quality)
124+
data = {'quality': quality}
125+
response = requests.get(urljoin(IVYSILANI_PLAYLIST_LINK, program_id), data, timeout=10)
126+
logging.debug("Ivysilani playlist response[%s]: %s", response.status_code, response.text)
127+
response.raise_for_status()
128+
129+
playlist_data = response.json()
130+
return playlist_data["streams"][0]["url"]
152131

153132

154133
################################################################################
155-
def run_player(playlist: m3u8.model.Playlist, player_cmd: str):
134+
def run_player(playlist: str, player_cmd: str) -> None:
156135
"""Run the video player.
157136
158-
@param playlist: Playlist to be played
159-
@param player_cmd: Additional player arguments
137+
@param playlist: Playlist URL to be played
138+
@param player_cmd: Player command
160139
"""
161-
cmd = shlex.split(player_cmd) + [playlist.absolute_uri]
140+
cmd = shlex.split(player_cmd) + [playlist]
162141
logging.debug("Player cmd: %s", cmd)
163142
subprocess.call(cmd) # nosec
164143

165144

166-
def play(options):
167-
"""Play televize.
145+
def play_live(options: Dict[str, Any]) -> None:
146+
"""Play live channel."""
147+
playlist = get_live_playlist(options['<channel>'], options['--quality'])
148+
run_player(playlist, options['--player'])
149+
150+
151+
PORADY_PATH_PATTERN = re.compile(r'^/porady/[^/]+/(?P<playlist_id>\d+)(-[^/]*)?/?$')
152+
153+
154+
def play_ivysilani(options: Dict[str, Any]) -> None:
155+
"""Play live channel.
168156
169-
@raises ValueError: In case of an invalid options.
157+
Raises:
158+
ValueError: Program not found.
170159
"""
171-
quality = parse_quality(options['--quality'])
172-
if options['live']:
173-
if options['<channel>'] not in CHANNEL_NAMES:
174-
raise ValueError("Unknown live channel '{}'".format(options['<channel>']))
175-
playlist = get_live_playlist(options['<channel>'], quality)
176-
else:
177-
assert options['ivysilani'] # nosec
178-
playlist = get_ivysilani_playlist(options['<url>'], quality)
179-
run_player(playlist, options['--player'])
160+
# Porady pages have playlist ID in URL
161+
split = urlsplit(options["<url>"])
162+
match = PORADY_PATH_PATTERN.match(split.path)
163+
if match:
164+
playlist_id = match.group('playlist_id')
165+
playlist = get_ivysilani_playlist(playlist_id, options['--quality'])
166+
run_player(playlist, options['--player'])
180167

168+
if not match:
169+
# TODO: Fetch porady page and play the most recent video.
170+
raise ValueError("Video not found.")
181171

182-
def main():
172+
173+
def main() -> None:
183174
"""Play Czech television stream in custom player."""
184175
options = docopt(__doc__, version=__version__)
185176

@@ -192,7 +183,14 @@ def main():
192183
logging.getLogger('iso8601').setLevel(logging.WARN)
193184

194185
try:
195-
play(options)
186+
channels = get_channels()
187+
if options['channels']:
188+
print_channels(channels)
189+
elif options['live']:
190+
play_live(options)
191+
else:
192+
assert options['ivysilani'] # nosec
193+
play_ivysilani(options)
196194
except Exception as error:
197195
if level == logging.DEBUG:
198196
logging.exception("An error occured:")

tests/data/ivysilani.html

Lines changed: 0 additions & 20 deletions
This file was deleted.

tests/data/ivysilani_no_button.html

Lines changed: 0 additions & 15 deletions
This file was deleted.

0 commit comments

Comments
 (0)