Skip to content

Commit

Permalink
Create m3u8 with multiple languages while playing content - WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
thomas-ernest committed Sep 14, 2023
1 parent 4e7ad77 commit 55674ed
Show file tree
Hide file tree
Showing 13 changed files with 2,462 additions and 11 deletions.
7 changes: 4 additions & 3 deletions addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,11 @@ def display_collection(kind, program_id):
return plugin.finish(view.build_mixed_collection(plugin, kind, program_id, settings))


@plugin.route('/streams/<program_id>', name='streams')
def streams(program_id):
@plugin.route('/streams/<program_id>/<duration>', name='streams')
def streams(program_id, duration):
"""Play a multi language content."""
return plugin.finish(view.build_video_streams(plugin, settings, program_id))
# return plugin.finish(view.build_video_streams(plugin, settings, program_id))
return plugin.set_resolved_url(view.build_multilang_video(plugin, settings, program_id, duration))


@plugin.route('/play_live/<stream_url>', name='play_live')
Expand Down
3 changes: 2 additions & 1 deletion addon.xml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.arteplussept" name="Arte +7" version="1.4.0" provider-name="bmf, thomas-ernest">
<addon id="plugin.video.arteplussept" name="Arte +7" version="1.5.0" provider-name="bmf, thomas-ernest">
<!-- https://kodi.wiki/view/Addon.xml -->
<requires>
<import addon="xbmc.python" version="3.0.0"/>
<import addon="script.module.xbmcswift2" version="19.0.4"/>
<import addon="script.module.requests" version="2.22.0"/>
<import addon="script.module.dateutil" version="2.8.1"/>
<import addon="script.module.m3u8" version="3.5.0"/>
</requires>
<extension point="xbmc.python.pluginsource" library="addon.py">
<provides>video</provides>
Expand Down
58 changes: 58 additions & 0 deletions resources/lib/m3u8/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# coding: utf-8
# Copyright 2014 Globo.com Player authors. All rights reserved.
# Use of this source code is governed by a MIT License
# license that can be found in the LICENSE file.

import sys
import os

from urllib.parse import urljoin, urlsplit

from resources.lib.m3u8.httpclient import DefaultHTTPClient
from resources.lib.m3u8.model import (M3U8, Segment, SegmentList, PartialSegment,
PartialSegmentList, Key, Playlist, IFramePlaylist,
Media, MediaList, PlaylistList, Start,
RenditionReport, RenditionReportList, ServerControl,
Skip, PartInformation, PreloadHint, DateRange,
DateRangeList, ContentSteering)
from resources.lib.m3u8.parser import parse, ParseError


__all__ = ('M3U8', 'Segment', 'SegmentList', 'PartialSegment',
'PartialSegmentList', 'Key', 'Playlist', 'IFramePlaylist',
'Media', 'MediaList', 'PlaylistList', 'Start', 'RenditionReport',
'RenditionReportList', 'ServerControl', 'Skip', 'PartInformation',
'PreloadHint', 'DateRange', 'DateRangeList', 'ContentSteering',
'loads', 'load', 'parse', 'ParseError')

def loads(content, uri=None, custom_tags_parser=None):
'''
Given a string with a m3u8 content, returns a M3U8 object.
Optionally parses a uri to set a correct base_uri on the M3U8 object.
Raises ValueError if invalid content
'''

if uri is None:
return M3U8(content, custom_tags_parser=custom_tags_parser)
else:
base_uri = urljoin(uri, '.')
return M3U8(content, base_uri=base_uri, custom_tags_parser=custom_tags_parser)


def load(uri, timeout=None, headers={}, custom_tags_parser=None, http_client=DefaultHTTPClient(), verify_ssl=True):
'''
Retrieves the content from a given URI and returns a M3U8 object.
Raises ValueError if invalid content or IOError if request fails.
'''
if urlsplit(uri).scheme:
content, base_uri = http_client.download(uri, timeout, headers, verify_ssl)
return M3U8(content, base_uri=base_uri, custom_tags_parser=custom_tags_parser)
else:
return _load_from_file(uri, custom_tags_parser)


def _load_from_file(uri, custom_tags_parser=None):
with open(uri, encoding='utf8') as fileobj:
raw_content = fileobj.read().strip()
base_uri = os.path.dirname(uri)
return M3U8(raw_content, base_uri=base_uri, custom_tags_parser=custom_tags_parser)
32 changes: 32 additions & 0 deletions resources/lib/m3u8/httpclient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import ssl
import urllib.request

from urllib.parse import urljoin


class DefaultHTTPClient:

def __init__(self, proxies=None):
self.proxies = proxies

def download(self, uri, timeout=None, headers={}, verify_ssl=True):
proxy_handler = urllib.request.ProxyHandler(self.proxies)
https_handler = HTTPSHandler(verify_ssl=verify_ssl)
opener = urllib.request.build_opener(proxy_handler, https_handler)
opener.addheaders = headers.items()
resource = opener.open(uri, timeout=timeout)
base_uri = urljoin(resource.geturl(), '.')
content = resource.read().decode(
resource.headers.get_content_charset(failobj="utf-8")
)
return content, base_uri


class HTTPSHandler:

def __new__(self, verify_ssl=True):
context = ssl.create_default_context()
if not verify_ssl:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return urllib.request.HTTPSHandler(context=context)
162 changes: 162 additions & 0 deletions resources/lib/m3u8/iso8601.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""ISO 8601 date time string parsing
Basic usage:
>>> import iso8601
>>> iso8601.parse_date("2007-01-25T12:00:00Z")
datetime.datetime(2007, 1, 25, 12, 0, tzinfo=<iso8601.Utc ...>)
>>>
"""

import datetime
import re
import typing
from decimal import Decimal

__all__ = ["parse_date", "ParseError", "UTC", "FixedOffset"]

# Adapted from http://delete.me.uk/2005/03/iso8601.html
ISO8601_REGEX = re.compile(
r"""
(?P<year>[0-9]{4})
(
(
(-(?P<monthdash>[0-9]{1,2}))
|
(?P<month>[0-9]{2})
(?!$) # Don't allow YYYYMM
)
(
(
(-(?P<daydash>[0-9]{1,2}))
|
(?P<day>[0-9]{2})
)
(
(
(?P<separator>[ T])
(?P<hour>[0-9]{2})
(:{0,1}(?P<minute>[0-9]{2})){0,1}
(
:{0,1}(?P<second>[0-9]{1,2})
([.,](?P<second_fraction>[0-9]+)){0,1}
){0,1}
(?P<timezone>
Z
|
(
(?P<tz_sign>[-+])
(?P<tz_hour>[0-9]{2})
:{0,1}
(?P<tz_minute>[0-9]{2}){0,1}
)
){0,1}
){0,1}
)
){0,1} # YYYY-MM
){0,1} # YYYY only
$
""",
re.VERBOSE,
)


class ParseError(ValueError):
"""Raised when there is a problem parsing a date string"""


UTC = datetime.timezone.utc


def FixedOffset(
offset_hours: float, offset_minutes: float, name: str
) -> datetime.timezone:
return datetime.timezone(
datetime.timedelta(hours=offset_hours, minutes=offset_minutes), name
)


def parse_timezone(
matches: typing.Dict[str, str],
default_timezone: typing.Optional[datetime.timezone] = UTC,
) -> typing.Optional[datetime.timezone]:
"""Parses ISO 8601 time zone specs into tzinfo offsets"""
tz = matches.get("timezone", None)
if tz == "Z":
return UTC
# This isn't strictly correct, but it's common to encounter dates without
# timezones so I'll assume the default (which defaults to UTC).
# Addresses issue 4.
if tz is None:
return default_timezone
sign = matches.get("tz_sign", None)
hours = int(matches.get("tz_hour", 0))
minutes = int(matches.get("tz_minute", 0))
description = f"{sign}{hours:02d}:{minutes:02d}"
if sign == "-":
hours = -hours
minutes = -minutes
return FixedOffset(hours, minutes, description)


def parse_date(
datestring: str, default_timezone: typing.Optional[datetime.timezone] = UTC
) -> datetime.datetime:
"""Parses ISO 8601 dates into datetime objects
The timezone is parsed from the date string. However it is quite common to
have dates without a timezone (not strictly correct). In this case the
default timezone specified in default_timezone is used. This is UTC by
default.
:param datestring: The date to parse as a string
:param default_timezone: A datetime tzinfo instance to use when no timezone
is specified in the datestring. If this is set to
None then a naive datetime object is returned.
:returns: A datetime.datetime instance
:raises: ParseError when there is a problem parsing the date or
constructing the datetime instance.
"""
try:
m = ISO8601_REGEX.match(datestring)
except Exception as e:
raise ParseError(e)

if not m:
raise ParseError(f"Unable to parse date string {datestring!r}")

# Drop any Nones from the regex matches
# TODO: check if there's a way to omit results in regexes
groups: typing.Dict[str, str] = {
k: v for k, v in m.groupdict().items() if v is not None
}

try:
return datetime.datetime(
year=int(groups.get("year", 0)),
month=int(groups.get("month", groups.get("monthdash", 1))),
day=int(groups.get("day", groups.get("daydash", 1))),
hour=int(groups.get("hour", 0)),
minute=int(groups.get("minute", 0)),
second=int(groups.get("second", 0)),
microsecond=int(
Decimal(f"0.{groups.get('second_fraction', 0)}") * Decimal("1000000.0")
),
tzinfo=parse_timezone(groups, default_timezone=default_timezone),
)
except Exception as e:
raise ParseError(e)


def is_iso8601(datestring: str) -> bool:
"""Check if a string matches an ISO 8601 format.
:param datestring: The string to check for validity
:returns: True if the string matches an ISO 8601 format, False otherwise
"""
try:
m = ISO8601_REGEX.match(datestring)
return bool(m)
except Exception as e:
raise ParseError(e)
52 changes: 52 additions & 0 deletions resources/lib/m3u8/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from os.path import dirname
from urllib.parse import urljoin, urlsplit


class BasePathMixin(object):

@property
def absolute_uri(self):
if self.uri is None:
return None

ret = urljoin(self.base_uri, self.uri)
if self.base_uri and (not urlsplit(self.base_uri).scheme):
return ret

if not urlsplit(ret).scheme:
raise ValueError('There can not be `absolute_uri` with no `base_uri` set')

return ret

@property
def base_path(self):
if self.uri is None:
return None
return dirname(self.get_path_from_uri())

def get_path_from_uri(self):
"""Some URIs have a slash in the query string."""
return self.uri.split("?")[0]

@base_path.setter
def base_path(self, newbase_path):
if self.uri is not None:
if not self.base_path:
self.uri = "%s/%s" % (newbase_path, self.uri)
else:
self.uri = self.uri.replace(self.base_path, newbase_path)


class GroupedBasePathMixin(object):

def _set_base_uri(self, new_base_uri):
for item in self:
item.base_uri = new_base_uri

base_uri = property(None, _set_base_uri)

def _set_base_path(self, newbase_path):
for item in self:
item.base_path = newbase_path

base_path = property(None, _set_base_path)
Loading

0 comments on commit 55674ed

Please sign in to comment.