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
910Subcommands:
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-
2115Options:
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"""
3024import shlex
3125import subprocess # nosec
3226import sys
33- from collections import OrderedDict
27+ from dataclasses import dataclass
28+ from typing import Any , Dict , Iterable , Optional
3429from urllib .parse import urljoin , urlsplit
3530
36- import m3u8
3731import requests
3832from 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:" )
0 commit comments