Skip to content

Commit

Permalink
[plugin.video.viwx] v1.3.0
Browse files Browse the repository at this point in the history
  • Loading branch information
dimkroon authored May 16, 2024
1 parent ab1c406 commit 401d178
Show file tree
Hide file tree
Showing 11 changed files with 346 additions and 64 deletions.
14 changes: 9 additions & 5 deletions plugin.video.viwx/addon.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.viwx" name="viwX" version="1.2.1" provider-name="Dimitri Kroon">
<addon id="plugin.video.viwx" name="viwX" version="1.3.0" provider-name="Dimitri Kroon">
<requires>
<import addon="xbmc.python" version="3.0.0"/>
<import addon="inputstream.adaptive" version="19.0.5"/>
Expand Down Expand Up @@ -30,12 +30,16 @@
<fanart>resources/fanart.png</fanart>
</assets>
<news>
[B]v1.2.1[/B]
[B]v1.3.0[/B]
[B]Fixes:[/B]
* All categories failed to open with KeyError('pathSegment') due to a change at ITVX.
* Freezing streams on Kodi 21 (Omega).
* A lot of timeout errors, only experienced by new users, particularly when trying to sign in or open a stream.
* All categories failed to open with KeyError('encodeEpisodeId') due to a change at ITVX.
* A workaround for a bug in ITVX causing full news programmes to fail with FetchError('Not Found').
* Sometimes an episodes listing failed with KeyError('guidance').

[B]Changes:[/B]
* Schedules of live channels are now listed up to 6 hours in the future (was 4 hrs).
[B]New Features:[/B]
* Support for IPTV Manager.
</news>
<reuselanguageinvoker>true</reuselanguageinvoker>
</extension>
Expand Down
11 changes: 11 additions & 0 deletions plugin.video.viwx/changelog.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
v1.3.0
Fixes:
- Freezing streams on Kodi 21 (Omega).
- A lot of timeout errors, only experienced by new users, particularly when trying to sign in or open a stream.
- All categories failed to open with KeyError('encodeEpisodeId') due to a change at ITVX.
- A workaround for a bug in ITVX causing full news programmes to fail with FetchError('Not Found').
- Sometimes an episodes listing failed with KeyError('guidance').

New features:


v1.2.1
Fixes:
- All categories failed to open with KeyError('pathSegment') due to a change at ITVX.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ msgctxt "#30121"
msgid "Offer to play from the start"
msgstr ""

msgctxt "#30131"
msgid "Install IPTV Manager"
msgstr ""

msgctxt "#30132"
msgid "Enable IPTV Manager integration"
msgstr ""

msgctxt "#30133"
msgid "Go to IPTV Manager settings"
msgstr ""

msgctxt "#30200"
msgid "itvX account"
msgstr ""
Expand Down
11 changes: 9 additions & 2 deletions plugin.video.viwx/resources/lib/cache.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ----------------------------------------------------------------------------------------------------------------------
# Copyright (c) 2022-2023 Dimitri Kroon.
# Copyright (c) 2022-2024 Dimitri Kroon.
# This file is part of plugin.video.viwx.
# SPDX-License-Identifier: GPL-2.0-or-later
# See LICENSE.txt
Expand All @@ -26,7 +26,14 @@

__cache = {}


# A list of programmeId's of programmes currently present in itvX's 'My List'.
# Used to determine whether to add an 'Add' or a 'Remove' option to a list
# item's context menu.
# Possible values are:
# None: The list has not yet been retrieved from ITVX.
# False: The list could not be obtained from ITVX, e.g. the user is not signed in.
# List: The list is initialised with the actual programmes in My List, could still
# be an empty list of course.
my_list_programmes = None


Expand Down
100 changes: 71 additions & 29 deletions plugin.video.viwx/resources/lib/fetch.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ----------------------------------------------------------------------------------------------------------------------
# Copyright (c) 2022-2023 Dimitri Kroon.
# Copyright (c) 2022-2024 Dimitri Kroon.
# This file is part of plugin.video.viwx.
# SPDX-License-Identifier: GPL-2.0-or-later
# See LICENSE.txt
Expand Down Expand Up @@ -102,6 +102,24 @@ def request(
return resp


def convert_consent(cookiejar):
"""Replace Cassie consent cookies for Syrenis.
"""
to_be_removed = []
# RequestCookieJar's items() returns a list of tuples
try:
for name, value in cookiejar.items():
if name.startswith("Cassie"):
del cookiejar[name]

set_default_cookies(cookiejar)
cookiejar.cassie_converted = True
cookiejar.save()
except:
logger.error("Error converting consent cookies:\n", exc_info=True)


def _create_cookiejar():
"""Restore a cookiejar from file. If the file does not exist create new one and
apply the default cookies.
Expand All @@ -117,70 +135,94 @@ def _create_cookiejar():
# if the file has been copied from another system.
cj.filename = cookie_file
logger.info("Restored cookies from file")
if not getattr(cj, "cassie_converted", None):
convert_consent(cj)

except (FileNotFoundError, pickle.UnpicklingError):
cj = set_default_cookies(PersistentCookieJar(cookie_file))
logger.info("Created new cookiejar")
return cj


def set_default_cookies(cookiejar: RequestsCookieJar = None):
"""Make a request to reject all cookies.
Ironically, the response sets third-party cookies to store that data.
Because of that they are rejected by requests, so the cookies are added
manually to the cookiejar.
"""Post a cookie consent form rejecting all cookies.
On success, set the required consent and other cookies.
Return the cookiejar
"""
from uuid import uuid4

s = requests.Session()
if isinstance(cookiejar, RequestsCookieJar):
s.cookies = cookiejar
elif cookiejar is not None:
raise ValueError("Parameter cookiejar must be an instance of RequestCookiejar")

my_guid = str(uuid4())

# noinspection PyBroadException
try:
# Make a request to reject all cookies.
resp = s.get(
'https://identityservice.syrenis.com/Home/SaveConsent',
params={'accessKey': '213aea86-31e5-43f3-8d6b-e01ba0d420c7',
'domain': '*.itv.com',
'consentedCookieIds': [],
'cookieFormConsent': '[{"FieldID":"s122_c113","IsChecked":0},{"FieldID":"s135_c126","IsChecked":0},'
'{"FieldID":"s134_c125","IsChecked":0},{"FieldID":"s138_c129","IsChecked":0},'
'{"FieldID":"s157_c147","IsChecked":0},{"FieldID":"s136_c127","IsChecked":0},'
'{"FieldID":"s137_c128","IsChecked":0}]',
'runFirstCookieIds': '[]',
'privacyCookieIds': '[]',
'custom1stPartyData': '[]',
'privacyLink': '1'},
resp = s.post(
'https://cscript-irl.cassiecloud.com/cookiesapi/submit',
headers={'User-Agent': USER_AGENT,
'Accept': 'application/json',
'Origin': 'https://www.itv.com/',
'Referer': 'https://www.itv.com/'},
timeout=WEB_TIMEOUT
timeout=WEB_TIMEOUT,
json={
"CookieFormID":5,
"LicenseID":"9FA306B9-83BD-4F83-A061-52D3589ABADB",
"DivID":"cassie-widget",
"Preferences":[
{"FieldID":"s122_c113","IsChecked":0},
{"FieldID":"s135_c126","IsChecked":0},
{"FieldID":"s134_c125","IsChecked":0},
{"FieldID":"s138_c129","IsChecked":0},
{"FieldID":"s157_c147","IsChecked":0},
{"FieldID":"s136_c127","IsChecked":0},
{"FieldID":"s137_c128","IsChecked":0}],
"appCodeName":"Mozilla",
"appName":"Netscape",
"appVersion":"5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"cookieEnabled":True,
"geolocation":"",
"language":"en",
"platform":"Linux x86_64",
"referrer":"",
"submissionSource":"prebanner_reject_all",
"visitGUID":my_guid,
"WebsiteURL":"https://www.itv.com/",
"PrivacyPolicyID":"1",
"custom1stPartyData":None}
)
s.close()
resp.raise_for_status()
consent = resp.json()['CassieConsent']
cookie_data = json.loads(consent)
jar = s.cookies
if resp.text != "Post Sucessful":
logger.warning("Unexpected response to cookie consent form: %s", resp.text)

jar = s.cookies
std_cookie_args = {'domain': '.itv.com', 'expires': time.time() + 3650 * 86400, 'discard': False}
for cookie_name, cookie_value in cookie_data.items():
jar.set(cookie_name, cookie_value, **std_cookie_args)
logger.info("updated cookies consent")

# Set consent cookies to reject all.
jar.set('SyrenisGuid_213aea86-31e5-43f3-8d6b-e01ba0d420c7', my_guid, **std_cookie_args)
jar.set('SyrenisCookieFormConsent_213aea86-31e5-43f3-8d6b-e01ba0d420c7',
'[{"FieldID":"s122_c113","IsChecked":0},{"FieldID":"s135_c126","IsChecked":0},{"FieldID":"s134_c125","IsChecked":0},{"FieldID":"s138_c129","IsChecked":0},{"FieldID":"s157_c147","IsChecked":0},{"FieldID":"s136_c127","IsChecked":0},{"FieldID":"s137_c128","IsChecked":0}]',
**std_cookie_args)
jar.set('SyrenisCookiePrivacyLink_213aea86-31e5-43f3-8d6b-e01ba0d420c7', '1', **std_cookie_args)
jar.set('SyrenisCookieConsentDate_213aea86-31e5-43f3-8d6b-e01ba0d420c7', str(int(time.time() * 1000)), **std_cookie_args)
logger.info("Updated consent cookies.")

# set other cookies
import uuid
jar.set('Itv.Cid', str(uuid.uuid4()), **std_cookie_args)
jar.set('Itv.Cid', str(uuid4()), **std_cookie_args)
jar.set('Itv.Region', 'ITV|null', **std_cookie_args)
jar.set("Itv.ParentalControls", '{"active":false,"pin":null,"question":null,"answer":null}', **std_cookie_args)
return jar
except:
logger.error("Unexpected exception while updating cookie consent", exc_info=True)
return cookiejar
finally:
s.close()


def web_request(method, url, headers=None, data=None, **kwargs):
Expand Down
102 changes: 102 additions & 0 deletions plugin.video.viwx/resources/lib/iptvmanager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@

# ----------------------------------------------------------------------------------------------------------------------
# Copyright (c) 2024 Dimitri Kroon.
# This file is part of plugin.video.viwx.
# SPDX-License-Identifier: GPL-2.0-or-later
# See LICENSE.txt
# ----------------------------------------------------------------------------------------------------------------------
import json
import socket
import xbmc

from codequick.script import Script
from codequick.support import build_path


# Logo URLs from now/next.
CHANNELS = {
'ITV': {'id': 'viwx.itv',
'name': 'ITV',
'logo': 'https://images.ctfassets.net/bd5zurrrnk1g/54OefyIkbiHPMJUYApbuUX/7dfe2176762fd8ec10f77cd61a318b07/itv1.png?w=512',
'preset': 1},
'ITV2': {'id': 'viwx.itv2',
'name': 'ITV2',
'logo': 'https://images.ctfassets.net/bd5zurrrnk1g/aV9MOsYOMEXHx3iw0p4tk/57b35173231c4290ff199ef8573367ad/itv2.png?w=512',
'preset': 2},
'ITVBe': {'id': 'viwx.itvbe',
'name': 'ITVBe',
'logo': 'https://images.ctfassets.net/bd5zurrrnk1g/6Mul5JVrb06pRu8bNDgIAe/b5309fa32322cc3db398d25e523e2b2e/itvBe.png?w=512',
'preset': 3},
'ITV3': {'id': 'viwx.itv3',
'name': 'ITV3',
'logo': 'https://images.ctfassets.net/bd5zurrrnk1g/39fJAu9LbUJptatyAs8HkL/80ac6eb141104854b209da946ae7a02f/itv3.png?w=512',
'preset': 4},
'ITV4': {'id': 'viwx.itv4',
'name': 'ITV4',
'logo': 'https://images.ctfassets.net/bd5zurrrnk1g/6Dv76O9mtWd6m7DzIavtsf/b3d491289679b8030eae7b4a7db58f2d/itv4.png?w=512',
'preset': 5}
}


# IPTVManager class from https://github.com/add-ons/service.iptv.manager/wiki/Integration
class IPTVManager:
"""Interface to IPTV Manager"""

def __init__(self, port):
"""Initialize IPTV Manager object"""
self.port = port

def via_socket(func):
"""Send the output of the wrapped function to socket"""

def send(self):
"""Decorator to send over a socket"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('127.0.0.1', self.port))
try:
sock.sendall(json.dumps(func(self)).encode())
finally:
sock.close()

return send

@via_socket
def send_channels(self):
"""Return JSON-STREAMS formatted python datastructure to IPTV Manager"""
from resources.lib.main import play_stream_live
chan_list = [
{
'id': chan_data.get('id'),
'name': chan_data.get('name'),
'logo': chan_data.get('logo'),
'stream': build_path(play_stream_live, query={'channel': name, 'url': None})
} for name, chan_data in CHANNELS.items()
]
return {'version': 1, 'streams': chan_list}

@via_socket
def send_epg(self):
"""Return JSON-EPG formatted python data structure to IPTV Manager"""
from resources.lib.itvx import get_full_schedule

schedules = get_full_schedule()
epg = {CHANNELS[k]['id']: v for k, v in schedules.items()}
return dict(version=1, epg=epg)


@Script.register
def channels(_, port):
try:
IPTVManager(int(port)).send_channels()
except Exception as err:
# Catch all errors to prevent codequick showing an error message
xbmc.log("[viwX] Error in iptvmanager.channels: {!r}.".format(err))


@Script.register
def epg(_, port):
try:
IPTVManager(int(port)).send_epg()
except Exception as err:
# Catch all errors to prevent codequick showing an error message
xbmc.log("[viwX] Error in iptvmanager.epg: {!r}.".format(err))
30 changes: 24 additions & 6 deletions plugin.video.viwx/resources/lib/itvx.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import requests
import xbmc

from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta

from codequick.support import logger_id

Expand Down Expand Up @@ -140,6 +140,24 @@ def get_live_channels(local_tz=None):
return schedule


def get_full_schedule():
"""Get the schedules of the main live channels from a week back to a week ahead.
These are from the html pages that the website uses to show schedules.
"""
today = datetime.utcnow()
all_days = (today + timedelta(i) for i in range(-7, 8))
# schedules = (get_page_data('watch/tv-guide/' + day.strftime('%Y-%m-%d')) for day in all_days)
schedule = {}
for day in all_days:
page_data = get_page_data('/watch/tv-guide/' + day.strftime('%Y-%m-%d'))
guide = page_data['tvGuideData']
for chan_name, progr_list in guide.items():
chan_schedule = schedule.setdefault(chan_name, [])
chan_schedule.extend(filter(None, (parsex.parse_schedule_item(progr) for progr in progr_list)))
return schedule


def main_page_items():
main_data = get_page_data('https://www.itv.com', cache_time=None)

Expand Down Expand Up @@ -532,12 +550,12 @@ def initialise_my_list():
my_list(itv_account.itv_session().user_id, offer_login=False, use_cache=False)
logger.info("Updated MyList programme ID's.")
except Exception as err:
# Since this runs before codequick.run() all exceptions must be caught to prevent them
# crashing the addon before the main menu is shown.
# Most likely the user is not (yet) logged in, but at the start of the addon connection
# errors could also occur, depending on the network setup.
logger.info("Failed to update MyList programme ID's: %r.", err)
pass
if cache.my_list_programmes is None:
# Mark my_list_programmes as 'failed to initialise'
# This causes listings not to include an 'Add' or 'Remove' context menu item,
# while preventing subsequent re-initialisation attempts.
cache.my_list_programmes = False


def get_last_watched():
Expand Down
Loading

0 comments on commit 401d178

Please sign in to comment.