Enigma2 plugin to to play various online streams (mostly Latvian).

vialib.py 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. # -*- coding: utf-8 -*-
  2. """
  3. A Kodi-agnostic library for Viaplay
  4. """
  5. import codecs
  6. import os
  7. import cookielib
  8. import calendar
  9. from datetime import datetime, timedelta
  10. import time
  11. from urllib import urlencode
  12. import re
  13. import json
  14. import uuid
  15. import HTMLParser
  16. import iso8601
  17. import requests
  18. import m3u8
  19. class Vialib(object):
  20. def __init__(self, username, password, cookie_file, deviceid_file, tempdir, country, ssl, debug=False):
  21. self.debug = debug
  22. self.username = username
  23. self.password = password
  24. self.country = country
  25. self.ssl = ssl
  26. self.deviceid_file = deviceid_file
  27. self.tempdir = tempdir
  28. self.http_session = requests.Session()
  29. self.cookie_jar = cookielib.LWPCookieJar(cookie_file)
  30. self.base_url = 'https://content.viaplay.%s/pc-%s' % (self.country, self.country)
  31. try:
  32. self.cookie_jar.load(ignore_discard=True, ignore_expires=True)
  33. except IOError:
  34. pass
  35. self.http_session.cookies = self.cookie_jar
  36. class LoginFailure(Exception):
  37. def __init__(self, value):
  38. self.value = value
  39. def __str__(self):
  40. return repr(self.value)
  41. class AuthFailure(Exception):
  42. def __init__(self, value):
  43. self.value = value
  44. def __str__(self):
  45. return repr(self.value)
  46. def log(self, string):
  47. if self.debug:
  48. try:
  49. print '[vialib]: %s' % string
  50. except UnicodeEncodeError:
  51. # we can't anticipate everything in unicode they might throw at
  52. # us, but we can handle a simple BOM
  53. bom = unicode(codecs.BOM_UTF8, 'utf8')
  54. print '[vialib]: %s' % string.replace(bom, '')
  55. except:
  56. pass
  57. def url_parser(self, url):
  58. """Sometimes, Viaplay adds some weird templated stuff to the URL
  59. we need to get rid of. Example: https://content.viaplay.se/androiddash-se/serier{?dtg}"""
  60. if not self.ssl:
  61. url = url.replace('https', 'http') # http://forum.kodi.tv/showthread.php?tid=270336
  62. template = re.search(r'\{.+?\}', url)
  63. if template:
  64. url = url.replace(template.group(), '')
  65. return url
  66. def make_request(self, url, method, payload=None, headers=None):
  67. """Make an HTTP request. Return the JSON response in a dict."""
  68. self.log('URL: %s' % url)
  69. parsed_url = self.url_parser(url)
  70. if parsed_url != url:
  71. url = parsed_url
  72. self.log('Parsed URL: %s' % url)
  73. if method == 'get':
  74. req = self.http_session.get(url, params=payload, headers=headers, allow_redirects=False, verify=False)
  75. else:
  76. req = self.http_session.post(url, data=payload, headers=headers, allow_redirects=False, verify=False)
  77. self.log('Response code: %s' % req.status_code)
  78. self.log('Response: %s' % req.content)
  79. self.cookie_jar.save(ignore_discard=True, ignore_expires=False)
  80. return json.loads(req.content)
  81. def login(self, username, password):
  82. """Login to Viaplay. Return True/False based on the result."""
  83. url = 'https://login.viaplay.%s/api/login/v1' % self.country
  84. payload = {
  85. 'deviceKey': 'pc-%s' % self.country,
  86. 'username': username,
  87. 'password': password,
  88. 'persistent': 'true'
  89. }
  90. data = self.make_request(url=url, method='get', payload=payload)
  91. if data['success'] is False:
  92. return False
  93. else:
  94. return True
  95. def validate_session(self):
  96. """Check if our session cookies are still valid."""
  97. url = 'https://login.viaplay.%s/api/persistentLogin/v1' % self.country
  98. payload = {
  99. 'deviceKey': 'pc-%s' % self.country
  100. }
  101. data = self.make_request(url=url, method='get', payload=payload)
  102. if data['success'] is False:
  103. return False
  104. else:
  105. return True
  106. def verify_login_status(self, data):
  107. """Check if we're logged in. If we're not, try to.
  108. Raise errors as LoginFailure."""
  109. if 'MissingSessionCookieError' in data.values():
  110. if not self.validate_session():
  111. if not self.login(self.username, self.password):
  112. raise self.LoginFailure('login failed')
  113. def get_video_urls(self, guid, pincode=None):
  114. """Return a dict with the stream URL:s and available subtitle URL:s."""
  115. video_urls = {}
  116. url = 'https://play.viaplay.%s/api/stream/byguid' % self.country
  117. payload = {
  118. 'deviceId': self.get_deviceid(),
  119. 'deviceName': 'web',
  120. 'deviceType': 'pc',
  121. 'userAgent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:47.0) Gecko/20100101 Firefox/47.0',
  122. 'deviceKey': 'pchls-%s' % self.country,
  123. 'guid': guid,
  124. 'pgPin': pincode
  125. }
  126. data = self.make_request(url=url, method='get', payload=payload)
  127. self.verify_login_status(data)
  128. # we might have to request the stream again after logging in
  129. if 'MissingSessionCookieError' in data.values():
  130. data = self.make_request(url=url, method='get', payload=payload)
  131. self.check_for_subscription(data)
  132. manifest_url = data['_links']['viaplay:playlist']['href']
  133. video_urls['manifest_url'] = manifest_url
  134. video_urls['bitrates'] = self.parse_m3u8_manifest(manifest_url)
  135. video_urls['subtitle_urls'] = self.get_subtitle_urls(data)
  136. return video_urls
  137. def check_for_subscription(self, data):
  138. """Check if the user is authorized to watch the requested stream.
  139. Raise errors as AuthFailure."""
  140. try:
  141. if data['success'] is False:
  142. subscription_error = data['name']
  143. raise self.AuthFailure(subscription_error)
  144. except KeyError:
  145. # 'success' won't be in response if it's successful
  146. pass
  147. def get_categories(self, input, method=None):
  148. if method == 'data':
  149. data = input
  150. else:
  151. data = self.make_request(url=input, method='get')
  152. if data['pageType'] == 'root':
  153. categories = data['_links']['viaplay:sections']
  154. elif data['pageType'] == 'section':
  155. categories = data['_links']['viaplay:categoryFilters']
  156. return categories
  157. def get_sortings(self, url):
  158. data = self.make_request(url=url, method='get')
  159. try:
  160. sorttypes = data['_links']['viaplay:sortings']
  161. except KeyError:
  162. self.log('No sortings available for this category.')
  163. sorttypes = None
  164. return sorttypes
  165. def get_letters(self, url):
  166. """Return a list of available letters for sorting in alphabetical order."""
  167. letters = []
  168. products = self.get_products(input=url, method='url')
  169. for item in products:
  170. letter = item['group'].encode('utf-8')
  171. if letter not in letters:
  172. letters.append(letter)
  173. return letters
  174. def get_products(self, input, method=None, filter_event=False):
  175. """Return a list of all available products."""
  176. if method == 'data':
  177. data = input
  178. else:
  179. data = self.make_request(url=input, method='get')
  180. if 'list' in data['type']:
  181. products = data['_embedded']['viaplay:products']
  182. elif data['type'] == 'product':
  183. products = data['_embedded']['viaplay:product']
  184. else:
  185. products = self.get_products_block(data)['_embedded']['viaplay:products']
  186. try:
  187. # try adding additional info to sports dict
  188. aproducts = []
  189. for product in products:
  190. if product['type'] == 'sport':
  191. product['event_date'] = self.parse_time(product['epg']['start'], localize=True)
  192. product['event_status'] = self.get_event_status(product)
  193. aproducts.append(product)
  194. products = aproducts
  195. except TypeError:
  196. pass
  197. if filter_event:
  198. fproducts = []
  199. for product in products:
  200. if filter_event == product['event_status']:
  201. fproducts.append(product)
  202. products = fproducts
  203. return products
  204. def get_seasons(self, url):
  205. """Return all available series seasons as a list."""
  206. seasons = []
  207. data = self.make_request(url=url, method='get')
  208. items = data['_embedded']['viaplay:blocks']
  209. for item in items:
  210. if item['type'] == 'season-list':
  211. seasons.append(item)
  212. return seasons
  213. def get_subtitle_urls(self, data):
  214. """Return all subtitle SAMI URL:s in a list."""
  215. subtitle_urls = []
  216. try:
  217. for subtitle in data['_links']['viaplay:sami']:
  218. subtitle_urls.append(subtitle['href'])
  219. except KeyError:
  220. self.log('No subtitles found for guid: %s' % data['socket2']['productGuid'])
  221. return subtitle_urls
  222. def download_subtitles(self, suburls):
  223. """Download the SAMI subtitles, decode the HTML entities and save to temp directory.
  224. Return a list of the path to the downloaded subtitles."""
  225. subtitle_paths = []
  226. for suburl in suburls:
  227. req = requests.get(suburl)
  228. sami = req.content.decode('utf-8', 'ignore').strip()
  229. htmlparser = HTMLParser.HTMLParser()
  230. subtitle = htmlparser.unescape(sami).encode('utf-8')
  231. subtitle = subtitle.replace(' ', ' ') # replace two spaces with one
  232. subpattern = re.search(r'[_]([a-z]+)', suburl)
  233. if subpattern:
  234. sublang = subpattern.group(1)
  235. else:
  236. sublang = 'unknown'
  237. self.log('Unable to identify subtitle language.')
  238. path = os.path.join(self.tempdir, '%s.sami') % sublang
  239. with open(path, 'w') as subfile:
  240. subfile.write(subtitle)
  241. subtitle_paths.append(path)
  242. return subtitle_paths
  243. def get_deviceid(self):
  244. """"Read/write deviceId (generated UUID4) from/to file and return it."""
  245. try:
  246. with open(self.deviceid_file, 'r') as deviceid:
  247. return deviceid.read()
  248. except IOError:
  249. deviceid = str(uuid.uuid4())
  250. with open(self.deviceid_file, 'w') as idfile:
  251. idfile.write(deviceid)
  252. return deviceid
  253. def get_event_status(self, data):
  254. """Return whether the event is live/upcoming/archive."""
  255. now = datetime.utcnow()
  256. producttime_start = self.parse_time(data['epg']['start'])
  257. producttime_start = producttime_start.replace(tzinfo=None)
  258. if 'isLive' in data['system']['flags']:
  259. status = 'live'
  260. elif producttime_start >= now:
  261. status = 'upcoming'
  262. else:
  263. status = 'archive'
  264. return status
  265. def get_sports_dates(self, url, event_date=None):
  266. """Return the available sports dates.
  267. Filter upcoming/previous dates with the event_date parameter."""
  268. dates = []
  269. data = self.make_request(url=url, method='get')
  270. dates_data = data['_links']['viaplay:days']
  271. now = datetime.now()
  272. for date in dates_data:
  273. date_obj = datetime(
  274. *(time.strptime(date['date'], '%Y-%m-%d')[0:6])) # http://forum.kodi.tv/showthread.php?tid=112916
  275. if event_date == 'upcoming':
  276. if date_obj.date() > now.date():
  277. dates.append(date)
  278. elif event_date == 'archive':
  279. if date_obj.date() < now.date():
  280. dates.append(date)
  281. else:
  282. dates.append(date)
  283. return dates
  284. def parse_m3u8_manifest(self, manifest_url):
  285. """Return the stream URL along with its bitrate."""
  286. streams = {}
  287. auth_cookie = None
  288. req = requests.get(manifest_url)
  289. m3u8_manifest = req.content
  290. self.log('HLS manifest: \n %s' % m3u8_manifest)
  291. if req.cookies:
  292. self.log('Cookies: %s' % req.cookies)
  293. # the auth cookie differs depending on the CDN
  294. if 'hdntl' and 'hdnts' in req.cookies.keys():
  295. hdntl_cookie = req.cookies['hdntl']
  296. hdnts_cookie = req.cookies['hdnts']
  297. auth_cookie = 'hdntl=%s; hdnts=%s' % (hdntl_cookie, hdnts_cookie)
  298. elif 'hdntl' in req.cookies.keys():
  299. hdntl_cookie = req.cookies['hdntl']
  300. auth_cookie = 'hdntl=%s' % hdntl_cookie
  301. elif 'lvlt_tk' in req.cookies.keys():
  302. lvlt_tk_cookie = req.cookies['lvlt_tk']
  303. auth_cookie = 'lvlt_tk=%s' % lvlt_tk_cookie
  304. else:
  305. self.log('No auth cookie found.')
  306. else:
  307. self.log('Stream request didn\'t contain any cookies.')
  308. m3u8_header = {'Cookie': auth_cookie}
  309. m3u8_obj = m3u8.loads(m3u8_manifest)
  310. for playlist in m3u8_obj.playlists:
  311. bitrate = int(playlist.stream_info.bandwidth) / 1000
  312. if playlist.uri.startswith('http'):
  313. stream_url = playlist.uri
  314. else:
  315. stream_url = manifest_url[:manifest_url.rfind('/') + 1] + playlist.uri
  316. streams[str(bitrate)] = stream_url + '|' + urlencode(m3u8_header)
  317. return streams
  318. def get_next_page(self, data):
  319. """Return the URL to the next page if the current page count is less than the total page count."""
  320. # first page is always (?) from viaplay:blocks
  321. if data['type'] == 'page':
  322. data = self.get_products_block(data)
  323. if int(data['pageCount']) > int(data['currentPage']):
  324. next_page_url = data['_links']['next']['href']
  325. return next_page_url
  326. def get_products_block(self, data):
  327. """Get the viaplay:blocks containing all product information."""
  328. blocks = []
  329. blocks_data = data['_embedded']['viaplay:blocks']
  330. for block in blocks_data:
  331. # example: https://content.viaplay.se/pc-se/sport
  332. if 'viaplay:products' in block['_embedded'].keys():
  333. blocks.append(block)
  334. return blocks[-1] # the last block is always (?) the right one
  335. def utc_to_local(self, utc_dt):
  336. # get integer timestamp to avoid precision lost
  337. timestamp = calendar.timegm(utc_dt.timetuple())
  338. local_dt = datetime.fromtimestamp(timestamp)
  339. assert utc_dt.resolution >= timedelta(microseconds=1)
  340. return local_dt.replace(microsecond=utc_dt.microsecond)
  341. def parse_time(self, iso8601_string, localize=False):
  342. """Parse ISO8601 string to datetime object."""
  343. datetime_obj = iso8601.parse_date(iso8601_string)
  344. if localize:
  345. datetime_obj = self.utc_to_local(datetime_obj)
  346. return datetime_obj
  347. if __name__ == "__main__":
  348. vp = Vialib("ivars777@gmail.com","kaskade7","cookie_file","device_id_file","tmp","se",True,True)
  349. data = vp.make_request(url=vp.base_url, method='get')
  350. pass