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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. # coding: utf-8
  2. # Copyright 2014 Globo.com Player authors. All rights reserved.
  3. # Use of this source code is governed by a MIT License
  4. # license that can be found in the LICENSE file.
  5. from collections import namedtuple
  6. import arrow
  7. import os
  8. import posixpath
  9. import errno
  10. import math
  11. try:
  12. import urlparse as url_parser
  13. except ImportError:
  14. import urllib.parse as url_parser
  15. from m3u8 import parser
  16. class M3U8(object):
  17. '''
  18. Represents a single M3U8 playlist. Should be instantiated with
  19. the content as string.
  20. Parameters:
  21. `content`
  22. the m3u8 content as string
  23. `base_path`
  24. all urls (key and segments url) will be updated with this base_path,
  25. ex.:
  26. base_path = "http://videoserver.com/hls"
  27. /foo/bar/key.bin --> http://videoserver.com/hls/key.bin
  28. http://vid.com/segment1.ts --> http://videoserver.com/hls/segment1.ts
  29. can be passed as parameter or setted as an attribute to ``M3U8`` object.
  30. `base_uri`
  31. uri the playlist comes from. it is propagated to SegmentList and Key
  32. ex.: http://example.com/path/to
  33. Attributes:
  34. `key`
  35. it's a `Key` object, the EXT-X-KEY from m3u8. Or None
  36. `segments`
  37. a `SegmentList` object, represents the list of `Segment`s from this playlist
  38. `is_variant`
  39. Returns true if this M3U8 is a variant playlist, with links to
  40. other M3U8s with different bitrates.
  41. If true, `playlists` is a list of the playlists available,
  42. and `iframe_playlists` is a list of the i-frame playlists available.
  43. `is_endlist`
  44. Returns true if EXT-X-ENDLIST tag present in M3U8.
  45. http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.8
  46. `playlists`
  47. If this is a variant playlist (`is_variant` is True), returns a list of
  48. Playlist objects
  49. `iframe_playlists`
  50. If this is a variant playlist (`is_variant` is True), returns a list of
  51. IFramePlaylist objects
  52. `playlist_type`
  53. A lower-case string representing the type of the playlist, which can be
  54. one of VOD (video on demand) or EVENT.
  55. `media`
  56. If this is a variant playlist (`is_variant` is True), returns a list of
  57. Media objects
  58. `target_duration`
  59. Returns the EXT-X-TARGETDURATION as an integer
  60. http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.2
  61. `media_sequence`
  62. Returns the EXT-X-MEDIA-SEQUENCE as an integer
  63. http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.3
  64. `program_date_time`
  65. Returns the EXT-X-PROGRAM-DATE-TIME as a string
  66. http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.5
  67. `version`
  68. Return the EXT-X-VERSION as is
  69. `allow_cache`
  70. Return the EXT-X-ALLOW-CACHE as is
  71. `files`
  72. Returns an iterable with all files from playlist, in order. This includes
  73. segments and key uri, if present.
  74. `base_uri`
  75. It is a property (getter and setter) used by
  76. SegmentList and Key to have absolute URIs.
  77. `is_i_frames_only`
  78. Returns true if EXT-X-I-FRAMES-ONLY tag present in M3U8.
  79. http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.12
  80. '''
  81. simple_attributes = (
  82. # obj attribute # parser attribute
  83. ('is_variant', 'is_variant'),
  84. ('is_endlist', 'is_endlist'),
  85. ('is_i_frames_only', 'is_i_frames_only'),
  86. ('target_duration', 'targetduration'),
  87. ('media_sequence', 'media_sequence'),
  88. ('program_date_time', 'program_date_time'),
  89. ('version', 'version'),
  90. ('allow_cache', 'allow_cache'),
  91. ('playlist_type', 'playlist_type')
  92. )
  93. def __init__(self, content=None, base_path=None, base_uri=None):
  94. if content is not None:
  95. self.data = parser.parse(content)
  96. else:
  97. self.data = {}
  98. self._base_uri = base_uri
  99. self._initialize_attributes()
  100. self.base_path = base_path
  101. def _initialize_attributes(self):
  102. self.key = Key(base_uri=self.base_uri, **self.data['key']) if 'key' in self.data else None
  103. self.segments = SegmentList([ Segment(base_uri=self.base_uri, **params)
  104. for params in self.data.get('segments', []) ])
  105. for attr, param in self.simple_attributes:
  106. setattr(self, attr, self.data.get(param))
  107. self.files = []
  108. if self.key:
  109. self.files.append(self.key.uri)
  110. self.files.extend(self.segments.uri)
  111. self.media = []
  112. for media in self.data.get('media', []):
  113. self.media.append(Media(uri=media.get('uri'),
  114. type=media.get('type'),
  115. group_id=media.get('group_id'),
  116. language=media.get('language'),
  117. name=media.get('name'),
  118. default=media.get('default'),
  119. autoselect=media.get('autoselect'),
  120. forced=media.get('forced'),
  121. characteristics=media.get('characteristics')))
  122. self.playlists = PlaylistList([ Playlist(base_uri=self.base_uri,
  123. media=self.media,
  124. **playlist)
  125. for playlist in self.data.get('playlists', []) ])
  126. self.iframe_playlists = PlaylistList()
  127. for ifr_pl in self.data.get('iframe_playlists', []):
  128. self.iframe_playlists.append(
  129. IFramePlaylist(base_uri=self.base_uri,
  130. uri=ifr_pl['uri'],
  131. iframe_stream_info=ifr_pl['iframe_stream_info'])
  132. )
  133. def __unicode__(self):
  134. return self.dumps()
  135. @property
  136. def base_uri(self):
  137. return self._base_uri
  138. @base_uri.setter
  139. def base_uri(self, new_base_uri):
  140. self._base_uri = new_base_uri
  141. self.segments.base_uri = new_base_uri
  142. @property
  143. def base_path(self):
  144. return self._base_path
  145. @base_path.setter
  146. def base_path(self, newbase_path):
  147. self._base_path = newbase_path
  148. self._update_base_path()
  149. def _update_base_path(self):
  150. if self._base_path is None:
  151. return
  152. if self.key:
  153. self.key.base_path = self.base_path
  154. self.segments.base_path = self.base_path
  155. self.playlists.base_path = self.base_path
  156. def add_playlist(self, playlist):
  157. self.is_variant = True
  158. self.playlists.append(playlist)
  159. def add_iframe_playlist(self, iframe_playlist):
  160. if iframe_playlist is not None:
  161. self.is_variant = True
  162. self.iframe_playlists.append(iframe_playlist)
  163. def add_media(self, media):
  164. self.media.append(media)
  165. def add_segment(self, segment):
  166. self.segments.append(segment)
  167. def dumps(self):
  168. '''
  169. Returns the current m3u8 as a string.
  170. You could also use unicode(<this obj>) or str(<this obj>)
  171. '''
  172. output = ['#EXTM3U']
  173. if self.media_sequence is not None:
  174. output.append('#EXT-X-MEDIA-SEQUENCE:' + str(self.media_sequence))
  175. if self.allow_cache:
  176. output.append('#EXT-X-ALLOW-CACHE:' + self.allow_cache.upper())
  177. if self.version:
  178. output.append('#EXT-X-VERSION:' + self.version)
  179. if self.key:
  180. output.append(str(self.key))
  181. if self.target_duration:
  182. output.append('#EXT-X-TARGETDURATION:' + int_or_float_to_string(self.target_duration))
  183. if self.program_date_time is not None:
  184. output.append('#EXT-X-PROGRAM-DATE-TIME:' + arrow.get(self.program_date_time).isoformat())
  185. if not (self.playlist_type is None or self.playlist_type == ''):
  186. output.append(
  187. '#EXT-X-PLAYLIST-TYPE:%s' % str(self.playlist_type).upper())
  188. if self.is_i_frames_only:
  189. output.append('#EXT-X-I-FRAMES-ONLY')
  190. if self.is_variant:
  191. for media in self.media:
  192. media_out = []
  193. if media.uri:
  194. media_out.append('URI=' + quoted(media.uri))
  195. if media.type:
  196. media_out.append('TYPE=' + media.type)
  197. if media.group_id:
  198. media_out.append('GROUP-ID=' + quoted(media.group_id))
  199. if media.language:
  200. media_out.append('LANGUAGE=' + quoted(media.language))
  201. if media.name:
  202. media_out.append('NAME=' + quoted(media.name))
  203. if media.default:
  204. media_out.append('DEFAULT=' + media.default)
  205. if media.autoselect:
  206. media_out.append('AUTOSELECT=' + media.autoselect)
  207. if media.forced:
  208. media_out.append('FORCED=' + media.forced)
  209. if media.characteristics:
  210. media_out.append('CHARACTERISTICS=' + quoted(media.characteristics))
  211. output.append('#EXT-X-MEDIA:' + ','.join(media_out))
  212. output.append(str(self.playlists))
  213. if self.iframe_playlists:
  214. output.append(str(self.iframe_playlists))
  215. output.append(str(self.segments))
  216. if self.is_endlist:
  217. output.append('#EXT-X-ENDLIST')
  218. return '\n'.join(output)
  219. def dump(self, filename):
  220. '''
  221. Saves the current m3u8 to ``filename``
  222. '''
  223. self._create_sub_directories(filename)
  224. with open(filename, 'w') as fileobj:
  225. fileobj.write(self.dumps())
  226. def _create_sub_directories(self, filename):
  227. basename = os.path.dirname(filename)
  228. try:
  229. os.makedirs(basename)
  230. except OSError as error:
  231. if error.errno != errno.EEXIST:
  232. raise
  233. class BasePathMixin(object):
  234. @property
  235. def absolute_uri(self):
  236. if parser.is_url(self.uri):
  237. return self.uri
  238. else:
  239. if self.base_uri is None:
  240. raise ValueError('There can not be `absolute_uri` with no `base_uri` set')
  241. return _urijoin(self.base_uri, self.uri)
  242. @property
  243. def base_path(self):
  244. return os.path.dirname(self.uri)
  245. @base_path.setter
  246. def base_path(self, newbase_path):
  247. if not self.base_path:
  248. self.uri = "%s/%s" % (newbase_path, self.uri)
  249. self.uri = self.uri.replace(self.base_path, newbase_path)
  250. class GroupedBasePathMixin(object):
  251. def _set_base_uri(self, new_base_uri):
  252. for item in self:
  253. item.base_uri = new_base_uri
  254. base_uri = property(None, _set_base_uri)
  255. def _set_base_path(self, newbase_path):
  256. for item in self:
  257. item.base_path = newbase_path
  258. base_path = property(None, _set_base_path)
  259. class Segment(BasePathMixin):
  260. '''
  261. A video segment from a M3U8 playlist
  262. `uri`
  263. a string with the segment uri
  264. `title`
  265. title attribute from EXTINF parameter
  266. `program_date_time`
  267. Returns the EXT-X-PROGRAM-DATE-TIME as a datetime
  268. http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.5
  269. `discontinuity`
  270. Returns a boolean indicating if a EXT-X-DISCONTINUITY tag exists
  271. http://tools.ietf.org/html/draft-pantos-http-live-streaming-13#section-3.4.11
  272. `duration`
  273. duration attribute from EXTINF parameter
  274. `base_uri`
  275. uri the key comes from in URI hierarchy. ex.: http://example.com/path/to
  276. `byterange`
  277. byterange attribute from EXT-X-BYTERANGE parameter
  278. `key`
  279. Key used to encrypt the segment (EXT-X-KEY)
  280. '''
  281. def __init__(self, uri, base_uri, program_date_time=None, duration=None,
  282. title=None, byterange=None, discontinuity=False, key=None):
  283. self.uri = uri
  284. self.duration = duration
  285. self.title = title
  286. self.base_uri = base_uri
  287. self.byterange = byterange
  288. self.program_date_time = program_date_time
  289. self.discontinuity = discontinuity
  290. self.key = Key(base_uri=base_uri,**key) if key else None
  291. def dumps(self, last_segment):
  292. output = []
  293. if last_segment and self.key != last_segment.key:
  294. output.append(str(self.key))
  295. output.append('\n')
  296. if self.discontinuity:
  297. output.append('#EXT-X-DISCONTINUITY\n')
  298. output.append('#EXT-X-PROGRAM-DATE-TIME:%s\n' % arrow.get(self.program_date_time).isoformat())
  299. output.append('#EXTINF:%s,' % int_or_float_to_string(self.duration))
  300. if self.title:
  301. output.append(quoted(self.title))
  302. output.append('\n')
  303. if self.byterange:
  304. output.append('#EXT-X-BYTERANGE:%s\n' % self.byterange)
  305. output.append(self.uri)
  306. return ''.join(output)
  307. def __str__(self):
  308. return self.dumps()
  309. class SegmentList(list, GroupedBasePathMixin):
  310. def __str__(self):
  311. output = []
  312. last_segment = None
  313. for segment in self:
  314. output.append(segment.dumps(last_segment))
  315. last_segment = segment
  316. return '\n'.join(output)
  317. @property
  318. def uri(self):
  319. return [seg.uri for seg in self]
  320. class Key(BasePathMixin):
  321. '''
  322. Key used to encrypt the segments in a m3u8 playlist (EXT-X-KEY)
  323. `method`
  324. is a string. ex.: "AES-128"
  325. `uri`
  326. is a string. ex:: "https://priv.example.com/key.php?r=52"
  327. `base_uri`
  328. uri the key comes from in URI hierarchy. ex.: http://example.com/path/to
  329. `iv`
  330. initialization vector. a string representing a hexadecimal number. ex.: 0X12A
  331. '''
  332. def __init__(self, method, uri, base_uri, iv=None):
  333. self.method = method
  334. self.uri = uri
  335. self.iv = iv
  336. self.base_uri = base_uri
  337. def __str__(self):
  338. output = [
  339. 'METHOD=%s' % self.method,
  340. 'URI="%s"' % self.uri,
  341. ]
  342. if self.iv:
  343. output.append('IV=%s' % self.iv)
  344. return '#EXT-X-KEY:' + ','.join(output)
  345. def __eq__(self, other):
  346. return self.method == other.method and \
  347. self.uri == other.uri and \
  348. self.iv == other.iv and \
  349. self.base_uri == other.base_uri
  350. def __ne__(self, other):
  351. return not self.__eq__(other)
  352. class Playlist(BasePathMixin):
  353. '''
  354. Playlist object representing a link to a variant M3U8 with a specific bitrate.
  355. Attributes:
  356. `stream_info` is a named tuple containing the attributes: `program_id`,
  357. `bandwidth`,`resolution`, `codecs` and `resolution` which is a a tuple (w, h) of integers
  358. `media` is a list of related Media entries.
  359. More info: http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.10
  360. '''
  361. def __init__(self, uri, stream_info, media, base_uri):
  362. self.uri = uri
  363. self.base_uri = base_uri
  364. resolution = stream_info.get('resolution')
  365. if resolution != None:
  366. values = resolution.split('x')
  367. resolution_pair = (int(values[0]), int(values[1]))
  368. else:
  369. resolution_pair = None
  370. self.stream_info = StreamInfo(bandwidth=stream_info['bandwidth'],
  371. program_id=stream_info.get('program_id'),
  372. resolution=resolution_pair,
  373. codecs=stream_info.get('codecs'))
  374. self.media = []
  375. for media_type in ('audio', 'video', 'subtitles'):
  376. group_id = stream_info.get(media_type)
  377. if not group_id:
  378. continue
  379. self.media += filter(lambda m: m.group_id == group_id, media)
  380. def __str__(self):
  381. stream_inf = []
  382. if self.stream_info.program_id:
  383. stream_inf.append('PROGRAM-ID=%d' % self.stream_info.program_id)
  384. if self.stream_info.bandwidth:
  385. stream_inf.append('BANDWIDTH=%d' % self.stream_info.bandwidth)
  386. if self.stream_info.resolution:
  387. res = str(self.stream_info.resolution[0]) + 'x' + str(self.stream_info.resolution[1])
  388. stream_inf.append('RESOLUTION=' + res)
  389. if self.stream_info.codecs:
  390. stream_inf.append('CODECS=' + quoted(self.stream_info.codecs))
  391. for media in self.media:
  392. media_type = media.type.upper()
  393. stream_inf.append('%s="%s"' % (media_type, media.group_id))
  394. return '#EXT-X-STREAM-INF:' + ','.join(stream_inf) + '\n' + self.uri
  395. class IFramePlaylist(BasePathMixin):
  396. '''
  397. IFramePlaylist object representing a link to a
  398. variant M3U8 i-frame playlist with a specific bitrate.
  399. Attributes:
  400. `iframe_stream_info` is a named tuple containing the attributes:
  401. `program_id`, `bandwidth`, `codecs` and `resolution` which
  402. is a tuple (w, h) of integers
  403. More info: http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.13
  404. '''
  405. def __init__(self, base_uri, uri, iframe_stream_info):
  406. self.uri = uri
  407. self.base_uri = base_uri
  408. resolution = iframe_stream_info.get('resolution')
  409. if resolution is not None:
  410. values = resolution.split('x')
  411. resolution_pair = (int(values[0]), int(values[1]))
  412. else:
  413. resolution_pair = None
  414. self.iframe_stream_info = StreamInfo(
  415. bandwidth=iframe_stream_info.get('bandwidth'),
  416. program_id=iframe_stream_info.get('program_id'),
  417. resolution=resolution_pair,
  418. codecs=iframe_stream_info.get('codecs')
  419. )
  420. def __str__(self):
  421. iframe_stream_inf = []
  422. if self.iframe_stream_info.program_id:
  423. iframe_stream_inf.append('PROGRAM-ID=%d' %
  424. self.iframe_stream_info.program_id)
  425. if self.iframe_stream_info.bandwidth:
  426. iframe_stream_inf.append('BANDWIDTH=%d' %
  427. self.iframe_stream_info.bandwidth)
  428. if self.iframe_stream_info.resolution:
  429. res = (str(self.iframe_stream_info.resolution[0]) + 'x' +
  430. str(self.iframe_stream_info.resolution[1]))
  431. iframe_stream_inf.append('RESOLUTION=' + res)
  432. if self.iframe_stream_info.codecs:
  433. iframe_stream_inf.append('CODECS=' +
  434. quoted(self.iframe_stream_info.codecs))
  435. if self.uri:
  436. iframe_stream_inf.append('URI=' + quoted(self.uri))
  437. return '#EXT-X-I-FRAME-STREAM-INF:' + ','.join(iframe_stream_inf)
  438. StreamInfo = namedtuple('StreamInfo', ['bandwidth', 'program_id', 'resolution', 'codecs'])
  439. Media = namedtuple('Media', ['uri', 'type', 'group_id', 'language', 'name',
  440. 'default', 'autoselect', 'forced', 'characteristics'])
  441. class PlaylistList(list, GroupedBasePathMixin):
  442. def __str__(self):
  443. output = [str(playlist) for playlist in self]
  444. return '\n'.join(output)
  445. def denormalize_attribute(attribute):
  446. return attribute.replace('_','-').upper()
  447. def quoted(string):
  448. return '"%s"' % string
  449. def _urijoin(base_uri, path):
  450. if parser.is_url(base_uri):
  451. parsed_url = url_parser.urlparse(base_uri)
  452. prefix = parsed_url.scheme + '://' + parsed_url.netloc
  453. new_path = posixpath.normpath(parsed_url.path + '/' + path)
  454. return url_parser.urljoin(prefix, new_path.strip('/'))
  455. else:
  456. return os.path.normpath(os.path.join(base_uri, path.strip('/')))
  457. def int_or_float_to_string(number):
  458. return str(int(number)) if number == math.floor(number) else str(number)