script.module.iptvlib/lib/iptvlib/api.py

481 lines
15 KiB
Python

# coding=utf-8
#
# Copyright (C) 2018 Dmitry Vinogradov
# https://github.com/kodi-iptv-addons
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Library General Public
# License as published by the Free Software Foundation; either
# version 2 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Library General Public License for more details.
#
# You should have received a copy of the GNU Library General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
# Boston, MA 02110-1301, USA.
#
import abc
import json
import os
import ssl
import threading
import traceback
import urllib.error
import urllib.parse
import urllib.request
from collections import OrderedDict
from queue import Queue
from threading import Event
from urllib.parse import urlencode
from urllib.request import Request
import xbmc
from . import build_user_agent, log, DAY, time_now, format_date, normalize
from .models import Group, Program, Channel
class ApiException(Exception):
def __init__(self, message, code, origin_error=None):
self.message = message
self.code = code
self.origin_error = origin_error
def __repr__(self):
return "ApiException: (%s) %s" % (self.code, self.message)
class HttpRequest(urllib.request.Request):
ident = None # type: str
method = None # type: str
def __init__(self, ident=None, method=None, **kwargs):
self.ident = ident
self.method = method
urllib.request.Request.__init__(self, **kwargs)
def get_method(self):
return self.method or urllib.request.Request.get_method(self)
def __repr__(self):
return "%s(%r)" % (self.__class__, self.__dict__)
class Api(metaclass=abc.ABCMeta):
AUTH_STATUS_NONE = 0
AUTH_STATUS_OK = 1
AUTH_MAX_ATTEMPTS = 3
E_UNKNOW_ERROR = 1000
E_HTTP_REQUEST_FAILED = 1001
E_JSON_DECODE = 1002
E_AUTH_ERROR = 1003
E_API_ERROR = 1004
auth_status = AUTH_STATUS_NONE # type: int
username = None # type: str
password = None # type: str
sort_channels = None # type: bool
working_path = None # type: str
cookie_file = None # type: str
settings_file = None # type: str
_attempt = 0 # type: int
_groups = None # type: OrderedDict[str, Group]
_channels = None # type: OrderedDict[str, Channel]
_ident = None # type: str
_epg_map = None # type: dict
def __init__(self, username=None, password=None, working_path="./", sort_channels=False):
self.auth_status = self.AUTH_STATUS_NONE
self.username = self._ident = username
self.password = password
self.working_path = working_path
self.sort_channels = sort_channels
if not os.path.exists(self.working_path):
os.makedirs(self.working_path)
self.cookie_file = os.path.join(self.working_path, "%s.cookie.txt" % self.__class__.__name__)
self.settings_file = os.path.join(self.working_path, "%s.settings.txt" % self.__class__.__name__)
self._groups = OrderedDict()
self._channels = OrderedDict()
@property
def client_id(self):
return "%s:%s" % (self.__class__.__name__, self._ident)
@property
def user_agent(self):
# type: () -> str
"""
User agent of HTTP client.
:rtype: str
"""
return build_user_agent()
@property
def groups(self):
if len(self._groups) == 0:
self._groups = OrderedDict(sorted(iter(self.get_groups().items()), key=lambda item: item[1].name)) \
if self.sort_channels else self.get_groups()
self._channels = OrderedDict()
for group in list(self._groups.values()):
channels = OrderedDict(sorted(iter(group.channels.items()), key=lambda item: item[1].name)) \
if self.sort_channels else group.channels
self._channels.update(channels)
return self._groups
@property
def channels(self):
if len(self._channels) == 0:
len(self.groups)
return self._channels
@property
def base_api_url(self):
# type: () -> str
"""
Base URL of API.
:rtype: str
"""
return ""
@property
def base_icon_url(self):
# type: () -> str
"""
Base URL of channel icons.
:rtype: str
"""
return ""
@property
def host(self):
# type: () -> str
"""
Api hostname.
:rtype: str
"""
return ""
@property
def diff_live_archive(self):
# type: () -> int
"""
Difference between live stream and archive stream in seconds.
:rtype: int
"""
return -1
@property
def archive_ttl(self):
# type: () -> int
"""
Time to live (ttl) of archive streams in seconds.
:rtype: int
"""
return -1
@abc.abstractmethod
def login(self):
# type: () -> dict
"""Login to the IPTV service"""
pass
@abc.abstractmethod
def is_login_request(self, uri, payload=None, method=None, headers=None):
# type: (str, dict, str, dict) -> bool
"""
Checks whether given URI is used for login
:rtype: str
"""
pass
@abc.abstractmethod
def get_groups(self):
# type: () -> OrderedDict[str, Group]
"""
Returns the groups from the service
:rtype: dict[str, Group]
"""
pass
@abc.abstractmethod
def get_stream_url(self, cid, ut_start=None):
# type: (str, int) -> str
"""
Returns the stream URL for given channel id and timestamp
:rtype: str
"""
pass
def resolve_url(self, url):
# type: (str) -> str
request = self.prepare_request(url)
try:
response = urllib.request.urlopen(request, context=ssl.create_default_context())
return response.url
except urllib.error.HTTPError:
return url
@abc.abstractmethod
def get_epg(self, cid):
# type: (str) -> dict[int, Program]
"""
Returns the EPG for given channel id
:rtype: dict[int, Program]
"""
pass
@abc.abstractmethod
def get_cookie(self):
# type: () -> str
"""
Returns cookie
:rtype: str
"""
pass
def read_cookie_file(self):
# type: () -> str
"""
Returns cookie stored in the cookie file
:rtype: str
"""
cookie = ""
if os.path.isfile(self.cookie_file):
with open(self.cookie_file, 'r') as fh:
cookie = fh.read()
fh.close()
return cookie
def write_cookie_file(self, data):
# type: (str) -> None
"""
Stores cookie to the cookie file
:rtype: None
"""
with open(self.cookie_file, 'w') as fh:
fh.write(data)
fh.close()
def read_settings_file(self):
# type: () -> dict
"""
Returns settings stored in the settings file
:rtype: dict
"""
settings = None
if os.path.isfile(self.settings_file):
with open(self.settings_file, 'r') as fh:
json_string = fh.read()
fh.close()
settings = json.loads(json_string)
return settings
def write_settings_file(self, data):
# type: (dict) -> None
"""
Stores settings to the settings file
:rtype: None
"""
with open(self.settings_file, 'w') as fh:
fh.write(json.dumps(data))
fh.close()
def prepare_request(self, uri, payload=None, method="GET", headers=None, ident=None):
# type: (str, dict, str, dict, str) -> HttpRequest
url = self.base_api_url % uri if uri.startswith('http') is False else uri
headers = headers or {}
headers["User-Agent"] = self.user_agent
headers["Connection"] = "Close"
cookie = self.get_cookie()
if cookie != "" and not self.is_login_request(uri, payload):
headers["Cookie"] = cookie
data = None
if payload:
if method == "POST":
if "Content-Type" in headers and headers["Content-Type"] == "application/json":
data = json.dumps(payload)
else:
data = urlencode(payload)
elif method == "GET":
url += "%s%s" % ("&" if "?" in url else "?", urlencode(payload))
ident = ident or url
return HttpRequest(ident=ident, method=method, url=url, headers=headers, data=data)
def send_request(self, request):
# type: (Request) -> dict|str
json_data = ""
try:
log("request: %s" % request, xbmc.LOGDEBUG)
response = urllib.request.urlopen(request) # type: addinfourl
content_type = response.headers.get("content-type") # type: str
content = response.read()
if not content_type.startswith("application/json"):
return content
response = json.loads(content)
except urllib.error.URLError as ex:
log("Exception %s: message=%s" % (type(ex), ex))
log(traceback.format_exc(), xbmc.LOGDEBUG)
response = {
"__error": {
"message": str(ex),
"code": self.E_HTTP_REQUEST_FAILED,
"details": {
"url": request.get_full_url(),
"data": request.data,
"headers": request.headers
},
}
}
pass
except ValueError as ex:
log("Exception %s: message=%s" % (type(ex), ex))
log(traceback.format_exc(), xbmc.LOGDEBUG)
response = {
"__error": {
"message": "Unable decode server response: %s" % str(ex),
"code": self.E_JSON_DECODE,
"details": {
"response": json_data
}
}
}
pass
except Exception as ex:
log("Exception %s: message=%s" % (type(ex), ex))
log(traceback.format_exc(), xbmc.LOGDEBUG)
response = {
"__error": {
"message": "%s: %s" % (type(ex), str(ex)),
"code": self.E_UNKNOW_ERROR
}
}
pass
return response
def make_request(self, uri, payload=None, method="GET", headers=None):
# type: (str, dict, str, dict) -> dict
"""
Makes HTTP request to the IPTV API
:param uri: URL of the IPTV API
:param payload: Payload data
:param method: HTTP method
:param headers: Additional HTTP headers
:return:
"""
if self.auth_status != self.AUTH_STATUS_OK and not self.is_login_request(uri, payload, method, headers):
self.login()
return self.make_request(uri, payload, method, headers)
request = self.prepare_request(uri, payload, method, headers)
response = self.send_request(request)
if "__error" in response:
if self.is_login_request(uri, payload):
self.auth_status = self.AUTH_STATUS_NONE
try:
os.remove(self.cookie_file)
except OSError:
pass
return response
def do_send_request(self, queue, results, stop_event, wait=None):
# type: (Queue, dict[str, dict], Event, float) -> None
while not stop_event.is_set():
request = queue.get() # type: HttpRequest
results[request.ident] = self.send_request(request)
queue.task_done()
stop_event.wait(wait)
def send_parallel_requests(self, requests, wait=None, num_threads=None):
# type: (list[HttpRequest], float, int) -> dict[str, dict]
num_threads = len(requests) if num_threads is None else num_threads
queue = Queue(num_threads * 2)
results = dict()
stop_event = threading.Event()
for i in range(num_threads):
thread = threading.Thread(target=self.do_send_request, args=(queue, results, stop_event, wait,))
thread.setDaemon(True)
thread.start()
for req in requests:
queue.put(req)
queue.join()
while queue.unfinished_tasks > 0:
continue
stop_event.set()
return results
def get_epg_gh(self, channel):
# type: (Channel) -> OrderedDict[int, Program]
programs = OrderedDict()
if self._epg_map is None:
self._epg_map = self.make_request("https://kodi-iptv-addons.github.io/EPG/map.json?%s" % time_now())
norm = normalize(channel.name)
if (norm in self._epg_map) is False:
return programs
cid = self._epg_map.get(norm)
requests = []
days = (self.archive_ttl // DAY) + 5
start = int(time_now() - self.archive_ttl)
for i in range(days):
day = format_date(start + (i * DAY), custom_format="%Y-%m-%d")
request = self.prepare_request(
"https://kodi-iptv-addons.github.io/EPG/%s/%s.json" % (cid, day)
)
requests.append(request)
results = self.send_parallel_requests(requests)
epg = dict()
for key in sorted(results.keys()):
response = results[key]
is_error, error = Api.is_error_response(response)
if is_error:
log("error: %s" % error if is_error else response, xbmc.LOGDEBUG)
return programs
for k, v in response.items():
epg[int(k)] = v
prev = None # type: Program
for key in sorted(epg.keys()):
val = epg[key]
program = Program(
channel.cid,
channel.gid,
val["start"],
val["stop"],
val["title"],
val["descr"],
channel.archive,
val["image"]
)
if prev is not None:
program.prev_program = prev
prev.next_program = program
programs[program.ut_start] = prev = program
return programs
@staticmethod
def is_error_response(response):
# type: (dict) -> (bool, dict or None)
if "__error" in response:
return True, response["__error"]
return False, None