diff --git a/plugin.video.viwx/LICENSE.txt b/plugin.video.viwx/LICENSE.txt
new file mode 100644
index 000000000..d8cf7d463
--- /dev/null
+++ b/plugin.video.viwx/LICENSE.txt
@@ -0,0 +1,280 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
diff --git a/plugin.video.viwx/addon.py b/plugin.video.viwx/addon.py
new file mode 100644
index 000000000..b315ab6b7
--- /dev/null
+++ b/plugin.video.viwx/addon.py
@@ -0,0 +1,22 @@
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
+from codequick import support
+from resources.lib import addon_log
+from resources.lib import main
+from resources.lib import utils
+from resources.lib import cc_patch
+
+
+cc_patch.patch_cc_route()
+cc_patch.patch_label_prop()
+
+
+if __name__ == '__main__':
+ utils.addon_info.initialise()
+ main.run()
+ addon_log.shutdown_log()
diff --git a/plugin.video.viwx/addon.xml b/plugin.video.viwx/addon.xml
new file mode 100644
index 000000000..689db9978
--- /dev/null
+++ b/plugin.video.viwx/addon.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ video
+
+
+ all
+ Live TV and video on-demand from ITVX (UK only, ITVX account required)
+
+ A Vibrant ITVX Web eXperience. Play live TV and video on-demand from the webservices of British broadcaster ITV. An ITVX account is required, either free or premium, to play any stream.[CR][CR]This add-on is not developed by ITV and is no way affiliated with, or endorsed by ITV.
+
+
+ This addon 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.
+
+ en
+ GPL-2.0-or-later
+ https://forum.kodi.tv/showthread.php?tid=374239
+
+
+ resources/icon.png
+ resources/fanart.png
+
+
+ First stable release
+
+ true
+
+
diff --git a/plugin.video.viwx/changelog.txt b/plugin.video.viwx/changelog.txt
new file mode 100644
index 000000000..ad5b6695b
--- /dev/null
+++ b/plugin.video.viwx/changelog.txt
@@ -0,0 +1,104 @@
+v 1.0.0
+ - First release.
+
+v1.0.0-rc.1
+- Support hero items and collection items of type collection.
+- Added some protection against future changes to ITVX's main page.
+- Fix: Some parts of subtitles missing when more than one colour was used on a single line.
+- Added support for an experimental addon that performs automatic subtitles translation.
+- Some other minor changes and improvements.
+
+v1.0.0-beta.6
+- Adapt to changes at ITVX which fixes:
+ - Almost all VOD fail to play.
+ - Programme folders fail to open.
+
+V1.0.0-beta.5
+- Start live streams a few seconds more in the past to fix stuttering and buffering issues.
+- Cleanup of some cookie and property black magic to fix other issues with stuttering and stalling live streams.
+- Adapt to changes at ITV which fixes:
+ - Missing 'News' collection on the add-on's home page.
+ - Most items in category 'News' fail to play.
+- Fix: All items now have the correct title and plot in the info screen while playing.
+- Fix: The addon becomes unresponsive for a very long time after an error.
+
+V1.0.0-beta.4
+- Fix: Very frequently a sign in to itvx is required.
+- Fix: Free playable items in collections and categories failed, claiming to be premium.
+- Fix: Premium content on the top level of a collection/category failed to play with a premium account.
+- A bit more resilient to errors in hero items.
+
+V1.0.0-beta.1
+- Renamed addon to viwx
+- Fix: some programmes with multiple episodes in categories were listed as single playable item, caused by changes at itvX.
+- Fix: page 'Live' failed to open with error 'NoneType is not subscriptable' caused by changes at itvX.
+- Fix: category 'News' failed to open caused by changes at itvX.
+- Fix: search sometimes does not return results when it should.
+- Fix: suport more types of hero items.
+- When available, local time zone is now obtained from Kodi's settings, rather than the OS.
+- VOD content in HD (720p, depending on the age of the episode).
+- Support more colours in subtitles
+
+v0.7.3
+- Fixed playable items in collections failed with error 'Not Found' caused by changes at itvX.
+- The main menu is always refreshed to ensure hero items are up to date.
+- Fixed regression in 0.7.2: live hero items failed to play.
+- Various minor changes under the hood.
+
+v0.7.2
+- Fix short news items from 'main menu/news' didn't play.
+- Fix 'main menu/news' sometimes failed with "KeyError 'synopsis'".
+- Fix fast channels won't play on kodi 20.
+
+v0.7.1
+- Fixed "KeyError 'type'" at addon start caused by changes at itvx.
+
+v0.7.0
+- Fix fast channels stop playing after a few minutes.
+- Fix crash of kodi 20 on playing video.
+- Proprer error message on access attempt to geo restricted content.
+- Now navigate into a series folder, rather then opening it in place.
+- Added settings to devide long lists up in pages and/or A-Z listing.
+- Series folder inherit the premium status of the program.
+- Series folders now have images.
+- Error message is shown on playing premium content without a premium account.
+- Times in live schedules now presented in the system locale/user preferences
+- Some other minor fixes, improvements and changes under the hood.
+
+v0.6.0
+- Added itvx promoted content to main menu.
+- Added itvx collections to main menu.
+- Removed shows A-Z due to lacking native support on itvx.
+- All other items are now also retrieved from itvx.
+- Added setting to hide paid itvX-premium content.
+- Add 'play from the start' to the context menu of the main live channels.
+- In live channel listing, display bare programme names rather than extended info.
+
+v.0.5.1
+- fix new icon not shown anymore after reboot
+
+v.0.5.0
+- itvX icon and fanart
+- Access to all itvX live channels.
+- Option to play live programs from the start, when enabled in settings. For the main live channels only.
+- Now possible to pause live programs.
+- New main menu item 'Search'.
+- Removed main menu item 'Full series'.
+
+v.0.4.0
+- List A-Z subfolders in menu 'Shows'.
+- Much improved account sign in experience.
+- Now possible to sign out of ITV account.
+- New setting to show password in plain text while you type.
+- Settings in new format, with help texts.
+- fix: catchup subtitles did not show anymore.
+- A little less confusing error messages.
+- Various small fixes, improvements, changes and adaptions to changes at ITV's web services.
+
+v.0.3.1
+fix: episode without subtitle fails to play
+update live channels' epg a bit more frequent
+fix: some episodes are missing in listings
+
+v.0.3.0
+Still a work in progress, but most things should be working reasonably well though
\ No newline at end of file
diff --git a/plugin.video.viwx/resources/__init__.py b/plugin.video.viwx/resources/__init__.py
new file mode 100644
index 000000000..eb2f5e87e
--- /dev/null
+++ b/plugin.video.viwx/resources/__init__.py
@@ -0,0 +1,6 @@
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
diff --git a/plugin.video.viwx/resources/fanart.png b/plugin.video.viwx/resources/fanart.png
new file mode 100644
index 000000000..dfaeb1601
Binary files /dev/null and b/plugin.video.viwx/resources/fanart.png differ
diff --git a/plugin.video.viwx/resources/icon.png b/plugin.video.viwx/resources/icon.png
new file mode 100644
index 000000000..113d5e230
Binary files /dev/null and b/plugin.video.viwx/resources/icon.png differ
diff --git a/plugin.video.viwx/resources/language/resource.language.en_gb/strings.po b/plugin.video.viwx/resources/language/resource.language.en_gb/strings.po
new file mode 100644
index 000000000..6a4bddcb9
--- /dev/null
+++ b/plugin.video.viwx/resources/language/resource.language.en_gb/strings.po
@@ -0,0 +1,281 @@
+# Kodi Media Center language file
+# Addon Name: viwX
+# Addon id: plugin.video.viwx
+# Addon Provider: Dim Kroon
+
+msgid ""
+msgstr ""
+"Project-Id-Version: Kodi Addons\n"
+"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n"
+"POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: en_GB\\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+# Settings
+msgctxt "#30100"
+msgid "General"
+msgstr ""
+
+msgctxt "#30101"
+msgid "Catchup programs"
+msgstr ""
+
+msgctxt "#30102"
+msgid "Use subtitles whenever available"
+msgstr ""
+
+msgctxt "#30103"
+msgid "Use coloured subtitles"
+msgstr ""
+
+msgctxt "#30104"
+msgid "Hide premium content"
+msgstr ""
+
+msgctxt "#30105"
+msgid "Maximum number of items per page"
+msgstr ""
+
+msgctxt "#30106"
+msgid "Minimum number of items for A-Z listing."
+msgstr ""
+
+msgctxt "#30110"
+msgid "Logging"
+msgstr ""
+
+msgctxt "#30111"
+msgid "Log to:"
+msgstr ""
+
+msgctxt "#30112"
+msgid "Kodi log,File,No logging"
+msgstr ""
+
+msgctxt "#30113"
+msgid "Log level"
+msgstr ""
+
+msgctxt "#30120"
+msgid "Live channels"
+msgstr ""
+
+msgctxt "#30121"
+msgid "Offer to play from the start"
+msgstr ""
+
+msgctxt "#30200"
+msgid "itvX account"
+msgstr ""
+
+msgctxt "#30201"
+msgid "Sign in"
+msgstr ""
+
+msgctxt "#30202"
+msgid "Sign out"
+msgstr ""
+
+msgctxt "#30203"
+msgid "Show password"
+msgstr ""
+
+# Help texts
+
+msgctxt "#30302"
+msgid "These settings only apply to catchup programs. Live programs may have embedded subtitles."
+msgstr ""
+
+msgctxt "#30303"
+msgid "If enabled subtitles will have different colours for different speakers, wherever possible.\n"
+"If disabled all subtitles are shown in white."
+msgstr ""
+
+msgctxt "#30304"
+msgid "Hide content that is only available with a paid itvX-premium account."
+msgstr ""
+
+msgctxt "#30305"
+msgid "The number of items per page. Listings with more items are split up into multiple pages.\n"
+"This affects a listing of a category or a collection."
+msgstr ""
+
+msgctxt "#30306"
+msgid "When a category or collection has more items than specified here, items will be alphabetically subdivided."
+msgstr ""
+
+msgctxt "#30311"
+msgid "Target of itvX logging.\n"
+"Default is the standard 'Kodi log' - nothing will be logged until 'debug logging' is enabled in Kodi's settings.\n"
+"'File' logs to itvX's own log file stored in the addon's user data directory. Only of use if you know where to "
+"find that file.\n"
+"'No logging' if you want to ensure the addon spends as little time on logging as possible."
+msgstr ""
+
+msgctxt "#30321"
+msgid "Show a dialog with the option to play the current program from the start each time a live channel is being started.\n"
+"When disabled 'play form the start' is still available on the context menu of the main live channels."
+msgstr ""
+
+msgctxt "#30401"
+msgid "You will be asked to enter your username and password after which the addon will try to sign in to "
+"your account. You will remain signed in until you sign out or sign in with another account."
+msgstr ""
+
+msgctxt "#30402"
+msgid "Sign out of your itvX account"
+msgstr ""
+
+msgctxt "#30403"
+msgid "Show password in plain text while you type.\n"
+"If disabled the password will be masked with '*' characters, making your password invisible to others, and yourself."
+msgstr ""
+
+# Script texts
+
+# Texts in dialogs
+msgctxt "#30604"
+msgid "More info"
+msgstr ""
+
+msgctxt "#30608"
+msgid "No items found"
+msgstr ""
+
+msgctxt "#30610"
+msgid "Account error"
+msgstr ""
+
+msgctxt "#30611"
+msgid "You need to sign in to your itvX account.\n"
+"You can sign in and out on the addon's settings page."
+msgstr ""
+
+msgctxt "#30612"
+msgid "Successfully signed in!"
+msgstr ""
+
+msgctxt "#30613"
+msgid "Signed out!"
+msgstr ""
+
+msgctxt "#30614"
+msgid "User name"
+msgstr ""
+
+msgctxt "#30615"
+msgid "Password"
+msgstr ""
+
+msgctxt "#30616"
+msgid "Invalid username"
+msgstr ""
+
+msgctxt "#30617"
+msgid "Invalid password"
+msgstr ""
+
+msgctxt "#30618"
+msgid "Try again?"
+msgstr ""
+
+msgctxt "#30619"
+msgid "Resume from"
+msgstr ""
+
+msgctxt "#30620"
+msgid "Play from the start"
+msgstr ""
+
+msgctxt "#30621"
+msgid "Open settings"
+msgstr ""
+
+msgctxt "#30622"
+msgid "This stream is only available to users with an itvX premium account"
+msgstr ""
+
+# Generic button texts
+msgctxt "#30790"
+msgid "OK"
+msgstr ""
+
+msgctxt "#30791"
+msgid "Cancel"
+msgstr ""
+
+msgctxt "#30792"
+msgid "YES"
+msgstr ""
+
+msgctxt "#30793"
+msgid "NO"
+msgstr ""
+
+# Menu texts
+msgctxt "#30801"
+msgid ""
+msgstr ""
+
+msgctxt "#30802"
+msgid ""
+msgstr ""
+
+msgctxt "#30803"
+msgid ""
+msgstr ""
+
+msgctxt "#30804"
+msgid ""
+msgstr ""
+
+msgctxt "#30805"
+msgid ""
+msgstr ""
+
+msgctxt "#30806"
+msgid ""
+msgstr ""
+
+msgctxt "#30807"
+msgid "Search"
+msgstr ""
+
+# Error texts
+msgctxt "#30901"
+msgid "Access not allowed."
+msgstr ""
+
+msgctxt "#30902"
+msgid "Not signed in at itvX."
+msgstr ""
+
+msgctxt "#30903"
+msgid "Sign in to account failed."
+msgstr ""
+
+msgctxt "#30904"
+msgid "Service is not available in this area."
+msgstr ""
+
+msgctxt "#30905"
+msgid "Connection error: {}."
+msgstr ""
+
+msgctxt "#30906"
+msgid "Error parsing data."
+msgstr ""
+
+msgctxt "#30920"
+msgid "Received invalid data."
+msgstr ""
+
+msgctxt "#30921"
+msgid "Stream info not available."
+msgstr ""
+
diff --git a/plugin.video.viwx/resources/lib/__init__.py b/plugin.video.viwx/resources/lib/__init__.py
new file mode 100644
index 000000000..1a9530cd0
--- /dev/null
+++ b/plugin.video.viwx/resources/lib/__init__.py
@@ -0,0 +1,7 @@
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
diff --git a/plugin.video.viwx/resources/lib/addon_log.py b/plugin.video.viwx/resources/lib/addon_log.py
new file mode 100644
index 000000000..18587487f
--- /dev/null
+++ b/plugin.video.viwx/resources/lib/addon_log.py
@@ -0,0 +1,119 @@
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
+
+import logging
+from logging.handlers import RotatingFileHandler
+import os
+import xbmc
+
+from codequick import Script
+from codequick.support import addon_data, logger_id
+
+
+kodi_lvl_map = {
+ logging.NOTSET: xbmc.LOGDEBUG,
+ logging.DEBUG: xbmc.LOGDEBUG,
+ logging.INFO: xbmc.LOGINFO,
+ logging.WARNING: xbmc.LOGWARNING,
+ logging.ERROR: xbmc.LOGERROR,
+ logging.CRITICAL: xbmc.LOGFATAL
+}
+
+
+class KodiLogHandler(logging.Handler):
+ def __init__(self):
+ super(KodiLogHandler, self).__init__()
+ self.setFormatter(logging.Formatter("[%(name)s] [%(levelname)s] %(message)s"))
+
+ def emit(self, record: logging.LogRecord) -> None:
+ msg = self.format(record)
+ # As per kodi requirements; only log at debug level to kodi log
+ xbmc.log(msg, xbmc.LOGDEBUG)
+
+
+class CtFileHandler(RotatingFileHandler):
+ def __init__(self):
+ file_name = (logger_id or 'addon') + '.log'
+ logfile = os.path.join(Script.get_info('profile'), file_name)
+ super(CtFileHandler, self).__init__(filename=logfile, mode='w', maxBytes=1000000, backupCount=2, encoding='utf8')
+ self.setFormatter(logging.Formatter('%(asctime)s %(levelname)-8s [%(name)s]: %(message)s'))
+
+ def __del__(self):
+ self.close()
+
+
+class DummyHandler(logging.Handler):
+ def __init__(self):
+ super(DummyHandler, self).__init__()
+ super(DummyHandler, self).setLevel(100)
+
+ def emit(self, record: logging.LogRecord) -> None:
+ pass
+
+ def setLevel(self, level) -> None:
+ pass
+
+
+def set_log_handler(handler_class):
+ to_be_removed = []
+ new_handler_present = False
+
+ for handler in logger.handlers:
+ # noinspection PyTypeHints
+ if not isinstance(handler, handler_class):
+ to_be_removed.append(handler)
+ else:
+ new_handler_present = True
+
+ # write to the old log
+ logger.info("Logging: change handler to: %s, to be removed: %s", handler_class.__name__,
+ [type(h).__name__ for h in to_be_removed])
+
+ for handler in to_be_removed:
+ logger.removeHandler(handler)
+ if isinstance(handler, logging.FileHandler):
+ handler.close()
+
+ if new_handler_present:
+ logger.info("Logging: kept original handler")
+ else:
+ logger.addHandler(handler_class())
+ # write to the new log
+ logger.info("Logging: changed handler to: %s, removed: %s", handler_class.__name__,
+ [type(h).__name__ for h in to_be_removed])
+
+
+logger = logging.getLogger(logger_id)
+logger.propagate = False
+
+# noinspection PyBroadException
+try:
+ handler_name = addon_data.getSettingString('log-handler').lower()
+except:
+ handler_name = 'kodi'
+
+if 'kodi' in handler_name:
+ logger.addHandler(KodiLogHandler())
+elif 'file' in handler_name:
+ logger.addHandler(CtFileHandler())
+else:
+ logger.addHandler(DummyHandler())
+
+# noinspection PyBroadException
+try:
+ log_lvl_idx = addon_data.getSettingInt('log-level')
+ log_lvl = (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR)[log_lvl_idx]
+except:
+ logger.warning("setting 'log-level' not present in addon settings")
+ log_lvl = logging.DEBUG
+
+logger.setLevel(log_lvl)
+
+
+def shutdown_log():
+ logging.shutdown()
diff --git a/plugin.video.viwx/resources/lib/cache.py b/plugin.video.viwx/resources/lib/cache.py
new file mode 100644
index 000000000..b01c06b72
--- /dev/null
+++ b/plugin.video.viwx/resources/lib/cache.py
@@ -0,0 +1,69 @@
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
+
+"""
+A very simple key-value store.
+Stores data in volatile memory for the lifetime of the addon or the specified period.
+"""
+
+
+import time
+import logging
+from copy import deepcopy
+
+from codequick.support import logger_id
+
+
+logger = logging.getLogger(logger_id + '.cache')
+# noinspection SpellCheckingInspection
+DFLT_EXPIRE_TIME = 600
+
+
+__cache = {}
+
+
+def get_item(key):
+ """Return the cached data if present in the cache and not expired.
+ Return None otherwise.
+
+ """
+ item = __cache.get(key)
+ if item and item['expires'] > time.monotonic():
+ logger.debug("Data cache: hit")
+ return deepcopy(item['data'])
+ else:
+ logger.debug("Data cache: miss")
+ return None
+
+
+def set_item(key, data, expire_time=DFLT_EXPIRE_TIME):
+ """Cache `data` in memory for the lifetime of the addon, to a maximum of `expire_time` in seconds.
+
+ """
+ item = dict(expires=time.monotonic() + expire_time,
+ data=deepcopy(data))
+ logger.debug("cached '%s'", key)
+ __cache[key] = item
+
+
+def clean():
+ """Remove expired items from the cache."""
+ now = time.monotonic()
+ for key, item in list(__cache.items()):
+ if item['expires'] < now:
+ logger.debug('Clean removed: %s', key)
+ del __cache[key]
+
+
+def purge():
+ """Empty the cache."""
+ __cache.clear()
+
+
+def size():
+ return len(__cache)
\ No newline at end of file
diff --git a/plugin.video.viwx/resources/lib/cc_patch.py b/plugin.video.viwx/resources/lib/cc_patch.py
new file mode 100644
index 000000000..87b800e1c
--- /dev/null
+++ b/plugin.video.viwx/resources/lib/cc_patch.py
@@ -0,0 +1,56 @@
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
+from codequick import Route, Listitem
+# noinspection PyProtectedMember
+from codequick.listing import strip_formatting
+
+
+def patch_cc_route():
+ """
+ Fixes a bug in codequick v1.0.2. by monkey patching
+ ``codequick.route.Route.__call__(...)``.
+
+ The problem is that decorating a callback with ``@Route.Register(cache_ttl=60)``
+ does not lead to the items the callback returns being cached.
+
+ This will be fixed if you call ``patch_cc_route()`` in the addon before
+ ``codequick.run()`` is being called.
+
+ """
+ original_call = Route.__call__
+
+ def patched_call(self, route, args, kwargs):
+ self.__dict__.update(route.parameters)
+ original_call(self, route, args, kwargs)
+
+ Route.__call__ = patched_call
+
+
+def patch_label_prop():
+ """
+ Fixes an annoyance in codequick v1.0.2. by monkey patching
+ ``codequick.listing.Listitem.label`` property.
+
+ The problem is that setting label on a codequick Listitem will always
+ set info['title'] as well, overwriting any existing value.
+ It is very nice that codequick ensures that relevant values are set, but
+ a more appropriate approach would be to only set those the addon developer
+ has not already set.
+
+ This will be fixed if you call ``patch_label_prop()`` in the addon before
+ ``codequick.run()`` is being called.
+
+ """
+ def label_setter(self, label):
+ """Set label and only copy to fields that do not already have a value"""
+ self.listitem.setLabel(label)
+ unformatted_label = strip_formatting("", label)
+ self.params.setdefault("_title_", unformatted_label)
+ self.info.setdefault("title", unformatted_label)
+
+ Listitem.label = Listitem.label.setter(label_setter)
diff --git a/plugin.video.viwx/resources/lib/errors.py b/plugin.video.viwx/resources/lib/errors.py
new file mode 100644
index 000000000..05fb8eec3
--- /dev/null
+++ b/plugin.video.viwx/resources/lib/errors.py
@@ -0,0 +1,47 @@
+
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
+
+class FetchError(IOError):
+ pass
+
+
+class AccountError(Exception):
+ def __init__(self, descr):
+ super(AccountError, self).__init__(descr)
+
+
+class AuthenticationError(FetchError):
+ def __init__(self, msg=None):
+ super(AuthenticationError, self).__init__(
+ msg or 'Login required')
+
+
+class GeoRestrictedError(FetchError):
+ def __init__(self, msg=None):
+ super(GeoRestrictedError, self).__init__(
+ msg or 'Service is not available in this area')
+
+
+class AccessRestrictedError(FetchError):
+ def __init__(self, msg=None):
+ super(AccessRestrictedError, self).__init__(
+ msg or 'Access not allowed')
+
+
+class HttpError(FetchError):
+ def __init__(self, code, reason):
+ self.code = code
+ self.reason = reason
+ super(HttpError, self).__init__(u'Connection error: {}'.format(reason))
+
+
+class ParseError(FetchError):
+ def __init__(self, msg=None):
+ super(ParseError, self).__init__(
+ msg or u'Error parsing data')
diff --git a/plugin.video.viwx/resources/lib/fetch.py b/plugin.video.viwx/resources/lib/fetch.py
new file mode 100644
index 000000000..060940756
--- /dev/null
+++ b/plugin.video.viwx/resources/lib/fetch.py
@@ -0,0 +1,270 @@
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
+import os
+import logging
+import requests
+import pickle
+import time
+from requests.cookies import RequestsCookieJar
+import json
+
+from codequick import Script
+from codequick.support import logger_id
+
+from resources.lib.errors import *
+from resources.lib import utils
+
+
+WEB_TIMEOUT = (3.5, 7)
+USER_AGENT = 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0'
+
+
+logger = logging.getLogger('.'.join((logger_id, __name__.split('.', 2)[-1])))
+
+
+class PersistentCookieJar(RequestsCookieJar):
+ def __init__(self, filename, policy=None):
+ RequestsCookieJar.__init__(self, policy)
+ self.filename = filename
+ self._has_changed = False
+
+ def save(self):
+ if not self._has_changed:
+ return
+ self.clear_expired_cookies()
+ self._has_changed = False
+ with open(self.filename, 'wb') as f:
+ pickle.dump(self, f, protocol=pickle.HIGHEST_PROTOCOL)
+ logger.info("Saved cookies to file %s", self.filename)
+
+ def set_cookie(self, cookie, *args, **kwargs):
+ super(PersistentCookieJar, self).set_cookie(cookie, *args, **kwargs)
+ logger.debug("Cookiejar sets cookie %s for %s%s to %s", cookie.name, cookie.domain, cookie.path, cookie.value)
+ self._has_changed |= cookie.name != 'hdntl'
+
+ def clear(self, domain=None, path=None, name=None) -> None:
+ try:
+ super(PersistentCookieJar, self).clear(domain, path, name)
+ logger.debug("Cookies cleared for domain: %s, path: %s, name %s", domain, path, name)
+ self._has_changed = True
+ except KeyError:
+ logger.debug("No cookies to clear for domain: %s, path: %s, name: %s ", domain, path, name)
+ pass
+
+
+class HttpSession(requests.sessions.Session):
+ instance = None
+
+ def __new__(cls):
+ if cls.instance is None:
+ cls.instance = super(HttpSession, cls).__new__(cls)
+ return cls.instance
+
+ def __init__(self):
+ if hasattr(self, 'cookies'):
+ # prevent re-initialization when __new__ returns an existing instance
+ return
+
+ super(HttpSession, self).__init__()
+ self.headers.update({
+ 'User-Agent': USER_AGENT,
+ 'Origin': 'https://www.itv.com',
+ 'Referer': 'https://www.itv.com/',
+ 'Sec-Fetch-Dest': 'empty',
+ 'Sec-Fetch-Mode': 'cors',
+ 'Sec-Fetch-Site': 'same-site',
+ 'Cache-Control': 'no-cache',
+ 'Pragma': 'no-cache',
+ })
+ self.cookies = _create_cookiejar()
+
+ # noinspection PyShadowingNames
+ def request(
+ self, method, url,
+ params=None, data=None, headers=None, cookies=None, files=None,
+ auth=None, timeout=None, allow_redirects=True, proxies=None,
+ hooks=None, stream=None, verify=None, cert=None, json=None):
+
+ resp = super(HttpSession, self).request(
+ method, url,
+ params=params, data=data, headers=headers, cookies=cookies, files=files,
+ auth=auth, timeout=timeout, allow_redirects=allow_redirects, proxies=proxies,
+ hooks=hooks, stream=stream, verify=verify, cert=cert, json=json)
+
+ # noinspection PyUnresolvedReferences
+ self.cookies.save()
+ return resp
+
+
+def _create_cookiejar():
+ """Restore a cookiejar from file. If the file does not exist create new one and
+ apply the default cookies.
+
+ """
+ cookie_file = os.path.join(utils.addon_info.profile, 'cookies')
+
+ try:
+ with open(cookie_file, 'rb') as f:
+ # TODO: handle expired consent cookies
+ cj = pickle.load(f)
+ # The internally stored filename of the saved file may be different to the current filename
+ # if the file has been copied from another system.
+ cj.filename = cookie_file
+ logger.info("Restored cookies from file")
+ 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.
+
+ Return the cookiejar
+
+ """
+ # noinspection PyBroadException
+ try:
+ 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")
+
+ # 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'},
+ headers={'User-Agent': USER_AGENT,
+ 'Accept': 'application/json',
+ 'Origin': 'https://www.itv.com/',
+ 'Referer': 'https://www.itv.com/'},
+ timeout=WEB_TIMEOUT
+ )
+ s.close()
+ resp.raise_for_status()
+ consent = resp.json()['CassieConsent']
+ cookie_data = json.loads(consent)
+ 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 other cookies
+ import uuid
+ jar.set('Itv.Cid', str(uuid.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
+
+
+def web_request(method, url, headers=None, data=None, **kwargs):
+ http_session = HttpSession()
+ kwargs.setdefault('timeout', WEB_TIMEOUT)
+ logger.debug("Making %s request to %s", method, url)
+ try:
+ resp = http_session.request(method, url, json=data, headers=headers, **kwargs)
+ resp.raise_for_status()
+ return resp
+ except requests.HTTPError as e:
+ # noinspection PyUnboundLocalVariable
+ logger.info("HTTP error %s for url %s: '%s'",
+ e.response.status_code,
+ url,
+ resp.content[:500] if resp.content is not None else '')
+
+ if 400 <= e.response.status_code < 500:
+ # noinspection PyBroadException
+ try:
+ resp_data = resp.json()
+ except:
+ # Intentional broad exception as requests can raise various types of errors
+ # depending on python, etc.
+ pass
+ else:
+ if resp_data.get('error') in ('invalid_grant', 'invalid_request'):
+ descr = resp_data.get("error_description", 'Login failed')
+ raise AuthenticationError(descr)
+ # Errors from https://magni.itv.com/playlist/itvonline:
+ if 'User does not have entitlements' in resp_data.get('Message', ''):
+ raise AccessRestrictedError()
+ if 'Outside Of Allowed Geographic Region' in resp_data.get('Message', ''):
+ raise GeoRestrictedError
+
+ if e.response.status_code == 401:
+ raise AuthenticationError()
+ else:
+ resp = e.response
+ raise HttpError(resp.status_code, resp.reason) from None
+ except requests.RequestException as e:
+ logger.error('Error connecting to %s: %r', url, e)
+ raise FetchError(str(e)) from None
+ finally:
+ http_session.close()
+
+
+def post_json(url, data, headers=None, **kwargs):
+ """Post JSON data and expect JSON data back."""
+ dflt_headers = {'Accept': 'application/json'}
+ if headers:
+ dflt_headers.update(headers)
+ resp = web_request('POST', url, dflt_headers, data, **kwargs)
+ try:
+ return resp.json()
+ except json.JSONDecodeError:
+ raise FetchError(Script.localize(30920))
+
+
+def get_json(url, headers=None, **kwargs):
+ """Make a GET reguest and expect JSON data back."""
+ dflt_headers = {'Accept': 'application/json'}
+ if headers:
+ dflt_headers.update(headers)
+ resp = web_request('GET', url, dflt_headers, **kwargs)
+ if resp.status_code == 204: # No Content
+ return None
+ try:
+ return resp.json()
+ except json.JSONDecodeError:
+ raise FetchError(Script.localize(30920))
+
+
+def put_json(url, data, headers=None, **kwargs):
+ """PUT JSON data and return the HTTP response, which can be inspected by the
+ caller for status, etc."""
+ resp = web_request('PUT', url, headers, data, **kwargs)
+ return resp
+
+
+def get_document(url, headers=None, **kwargs):
+ """GET any document. Expects the document to be UTF-8 encoded and returns
+ the contents as string.
+ It may be necessary to provide and 'Accept' header.
+ """
+ resp = web_request('GET', url, headers, **kwargs)
+ resp.encoding = 'utf8'
+ return resp.text
diff --git a/plugin.video.viwx/resources/lib/itv.py b/plugin.video.viwx/resources/lib/itv.py
new file mode 100644
index 000000000..ce19b1975
--- /dev/null
+++ b/plugin.video.viwx/resources/lib/itv.py
@@ -0,0 +1,217 @@
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
+import os
+import logging
+
+from datetime import datetime, timedelta
+import pytz
+import xbmc
+
+from codequick import Script
+from codequick.support import logger_id
+
+from . import utils
+from . import fetch
+from . import kodi_utils
+
+from .errors import AuthenticationError
+
+
+logger = logging.getLogger(logger_id + '.itv')
+
+
+def get_live_schedule(hours=4, local_tz=None):
+ """Get the schedule of the live channels from now up to the specified number of hours.
+
+ """
+ if local_tz is None:
+ local_tz = pytz.timezone('Europe/London')
+ btz = pytz.timezone('Europe/London')
+ british_now = datetime.now(pytz.utc).astimezone(btz)
+
+ # Request TV schedules for the specified number of hours from now, in british time
+ from_date = british_now.strftime('%Y%m%d%H%M')
+ to_date = (british_now + timedelta(hours=hours)).strftime('%Y%m%d%H%M')
+ # Note: platformTag=ctv is exactly what a webbrowser sends
+ url = 'https://scheduled.oasvc.itv.com/scheduled/itvonline/schedules?from={}&platformTag=ctv&to={}'.format(
+ from_date, to_date)
+ data = fetch.get_json(url)
+ schedules_list = data.get('_embedded', {}).get('schedule', [])
+ schedule = [element['_embedded'] for element in schedules_list]
+
+ # Convert British start time to local time and format in the user's regional format
+ # Use local time format without seconds. Fix weird kodi formatting for 12-hour clock.
+ time_format = xbmc.getRegion('time').replace(':%S', '').replace('%I%I:', '%I:')
+ strptime = utils.strptime
+ for channel in schedule:
+ for program in channel['slot']:
+ time_str = program['startTime'][:16]
+ brit_time = btz.localize(strptime(time_str, '%Y-%m-%dT%H:%M'))
+ program['startTime'] = brit_time.astimezone(local_tz).strftime(time_format)
+ program['orig_start'] = program['onAirTimeUTC'][:19]
+
+ return schedule
+
+
+stream_req_data = {
+ 'client': {
+ 'id': 'browser',
+ 'service': 'itv.x',
+ 'supportsAdPods': False,
+ 'version': '4.1'
+ },
+ 'device': {
+ 'manufacturer': 'Firefox',
+ 'model': '110',
+ 'os': {
+ 'name': 'Linux',
+ 'type': 'desktop',
+ }
+ },
+ 'user': {
+ 'entitlements': [],
+ 'itvUserId': '',
+ 'token': ''
+ },
+ 'variantAvailability': {
+ 'featureset': {
+ 'max': ['mpeg-dash', 'widevine', 'outband-webvtt', 'hd', 'single-track'],
+ 'min': ['mpeg-dash', 'widevine', 'outband-webvtt', 'hd', 'single-track']
+ },
+ 'platformTag': 'dotcom',
+ 'player': 'dash'
+ }
+}
+
+
+def _request_stream_data(url, stream_type='live', retry_on_error=True):
+ from .itv_account import itv_session
+ session = itv_session()
+
+ try:
+ stream_req_data['user']['token'] = session.access_token
+ stream_req_data['client']['supportsAdPods'] = stream_type != 'live'
+
+ if stream_type == 'live':
+ accept_type = 'application/vnd.itv.online.playlist.sim.v3+json'
+ # Live MUST have a featureset containing an item without outband-webvtt, or a bad request is returned.
+ min_features = ['mpeg-dash', 'widevine']
+ else:
+ accept_type = 'application/vnd.itv.vod.playlist.v2+json'
+ # ITV appears now to use the min feature for catchup streams, causing subtitles
+ # to go missing if not specified here. Min and max both specifying webvtt appears to
+ # be no problem for catchup streams that don't have subtitles.
+ min_features = ['mpeg-dash', 'widevine', 'outband-webvtt', 'hd', 'single-track']
+
+ stream_req_data['variantAvailability']['featureset']['min'] = min_features
+
+ stream_data = fetch.post_json(
+ url, stream_req_data,
+ headers={'Accept': accept_type},
+ cookies=session.cookie)
+
+ http_status = stream_data.get('StatusCode', 0)
+ if http_status == 401:
+ raise AuthenticationError
+
+ return stream_data
+ except AuthenticationError:
+ if retry_on_error:
+ if session.refresh():
+ return _request_stream_data(url, stream_type, retry_on_error=False)
+ else:
+ if kodi_utils.show_msg_not_logged_in():
+ from xbmc import executebuiltin
+ executebuiltin('Addon.OpenSettings({})'.format(utils.addon_info.id))
+ raise
+ else:
+ raise
+
+
+def get_live_urls(url=None, title=None, start_time=None, play_from_start=False):
+ """Return the urls to the dash stream, key service and subtitles for a particular live channel.
+
+ .. note::
+ Subtitles are usually embedded in live streams. Just return None in order to be compatible with
+ data returned by get_catchup_urls(...).
+
+ """
+ channel = url.rsplit('/', 1)[1]
+
+ stream_data = _request_stream_data(url)
+ video_locations = stream_data['Playlist']['Video']['VideoLocations'][0]
+ dash_url = video_locations['Url']
+ start_again_url = video_locations.get('StartAgainUrl')
+
+ if start_again_url:
+ if start_time and (play_from_start or kodi_utils.ask_play_from_start(title)):
+ dash_url = start_again_url.format(START_TIME=start_time)
+ logger.debug('get_live_urls - selected play from start at %s', start_time)
+ # Fast channels play only for about 5 minutes on the time shift stream
+ elif not channel.startswith('FAST'):
+ # Go 30 sec back to ensure we get the timeshift stream
+ start_time = datetime.utcnow() - timedelta(seconds=30)
+ dash_url = start_again_url.format(START_TIME=start_time.strftime('%Y-%m-%dT%H:%M:%S'))
+
+ key_service = video_locations['KeyServiceUrl']
+ return dash_url, key_service, None
+
+
+def get_catchup_urls(episode_url):
+ """Return the urls to the dash stream, key service and subtitles for a particular catchup
+ episode and the type of video.
+
+ """
+ playlist = _request_stream_data(episode_url, 'catchup')['Playlist']
+ stream_data = playlist['Video']
+ url_base = stream_data['Base']
+ video_locations = stream_data['MediaFiles'][0]
+ dash_url = url_base + video_locations['Href']
+ key_service = video_locations.get('KeyServiceUrl')
+ try:
+ # Usually stream_data['Subtitles'] is just None when subtitles are not available.
+ subtitles = stream_data['Subtitles'][0]['Href']
+ except (TypeError, KeyError, IndexError):
+ subtitles = None
+ return dash_url, key_service, subtitles, playlist['VideoType']
+
+
+def get_vtt_subtitles(subtitles_url):
+ """Return a tuple with the file paths to rst subtitles files. The tuple usually
+ has only one single element, but could contain more.
+
+ Return None if subtitles_url does not point to a valid Web-vvt subtitle file or
+ subtitles are not te be shown by user setting.
+
+ """
+ show_subtitles = Script.setting['subtitles_show'] == 'true'
+ if show_subtitles is False:
+ logger.info('Ignored subtitles by entry in settings')
+ return None
+
+ if not subtitles_url:
+ logger.info('No subtitles available for this stream')
+ return None
+
+ # noinspection PyBroadException
+ try:
+ vtt_doc = fetch.get_document(subtitles_url)
+
+ # vtt_file = os.path.join(utils.addon_info.profile, 'subtitles.vtt')
+ # with open(vtt_file, 'w', encoding='utf8') as f:
+ # f.write(vtt_doc)
+
+ srt_doc = utils.vtt_to_srt(vtt_doc, colourize=Script.setting['subtitles_color'] != 'false')
+ srt_file = os.path.join(utils.addon_info.profile, 'hearing impaired.en.srt')
+ with open(srt_file, 'w', encoding='utf8') as f:
+ f.write(srt_doc)
+
+ return (srt_file, )
+ except:
+ logger.error("Failed to get vtt subtitles from url %s", subtitles_url, exc_info=True)
+ return None
diff --git a/plugin.video.viwx/resources/lib/itv_account.py b/plugin.video.viwx/resources/lib/itv_account.py
new file mode 100644
index 000000000..d94edf902
--- /dev/null
+++ b/plugin.video.viwx/resources/lib/itv_account.py
@@ -0,0 +1,226 @@
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
+import time
+import os
+import json
+import logging
+
+from codequick.support import logger_id
+
+from . import utils
+from . import fetch
+from . import kodi_utils
+from .errors import *
+
+
+logger = logging.getLogger(logger_id + '.account')
+SESS_DATA_VERS = 2
+
+
+class ItvSession:
+ def __init__(self):
+ self.account_data = {}
+ self.read_account_data()
+
+ @property
+ def access_token(self):
+ """Return the cached access token, but refresh first if it has expired.
+ Raise AuthenticationError when the token is not present.
+
+ """
+ try:
+ if self.account_data['refreshed'] < time.time() - 4 * 3600:
+ # renew tokens periodically
+ logger.debug("Token cache time has expired.")
+ self.refresh()
+
+ return self.account_data['itv_session']['access_token']
+ except (KeyError, TypeError):
+ logger.debug("Cannot produce access token from account data: %s", self.account_data)
+ raise AuthenticationError
+
+ @property
+ def cookie(self):
+ """Return a dict containing the cookie required for authentication"""
+ try:
+ if self.account_data['refreshed'] < time.time() - 2 * 3600:
+ # renew tokens periodically
+ self.refresh()
+ return self.account_data['cookies']
+ except (KeyError, TypeError):
+ logger.debug("Cannot produce cookies from account data: %s", self.account_data)
+ raise AuthenticationError
+
+ def read_account_data(self):
+ session_file = os.path.join(utils.addon_info.profile, "itv_session")
+ logger.debug("Reading account data from file: %s", session_file)
+ try:
+ with open(session_file, 'r') as f:
+ acc_data = json.load(f)
+ except (OSError, IOError, ValueError) as err:
+ logger.error("Failed to read account data: %r" % err)
+ self.account_data = {}
+ return
+
+ if acc_data.get('vers') != SESS_DATA_VERS:
+ logger.info("Converting account data from version '%s' to version '%s'",
+ acc_data.get('vers'), SESS_DATA_VERS)
+ self.account_data = convert_session_data(acc_data)
+ self.save_account_data()
+ else:
+ self.account_data = acc_data
+
+ def save_account_data(self):
+ session_file = os.path.join(utils.addon_info.profile, "itv_session")
+ data_str = json.dumps(self.account_data)
+ with open(session_file, 'w') as f:
+ f.write(data_str)
+ logger.info("ITV account data saved to file")
+
+ def login(self, uname: str, passw: str):
+ """Sign in to itv account with `uname` and `passw`.
+
+ Returns True on success, raises exception on failure.
+ Raises AuthenticationError if login fails, or other exceptions as they occur, like e.g. FetchError.
+ """
+ self.account_data = {}
+
+ req_data = {
+ 'grant_type': 'password',
+ 'nonce': utils.random_string(20),
+ 'username': uname,
+ 'password': passw,
+ 'scope': 'content'
+ }
+
+ logger.info("Trying to sign in to ITV account")
+ try:
+ # Post credentials
+ session_data = fetch.post_json(
+ 'https://auth.prd.user.itv.com/auth',
+ req_data,
+ headers={'Accept': 'application/vnd.user.auth.v2+json',
+ 'Akamai-BM-Telemetry': '7a74G7m23Vrp0o5c9379441.75-1,2,-94,-100,Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0,uaend,11059,20100101,nl,Gecko,5,0,0,0,409881,2462846,1680,1010,1680,1050,1680,925,1680,,cpen:0,i1:0,dm:0,cwen:0,non:1,opc:0,fc:1,sc:0,wrc:1,isc:85,vib:1,bat:0,x11:0,x12:1,5663,0.09571883547,832931231423,0,loc:-1,2,-94,-131,-1,2,-94,-101,do_en,dm_en,t_dis-1,2,-94,-105,0,-1,0,0,2422,113,0;0,-1,0,0,1102,520,0;1,0,0,0,1465,883,0;-1,2,-94,-102,0,0,0,0,2422,113,0;0,-1,1,0,1102,520,0;1,0,1,0,1465,883,0;-1,2,-94,-108,0,1,11374,16,0,0,883;1,1,11749,-2,0,8,883;2,3,11749,-2,0,8,883;3,2,11909,-2,0,8,883;4,2,11941,16,0,8,883;5,1,12086,-2,0,0,883;6,3,12086,-2,0,0,883;7,2,12213,-2,0,0,883;8,1,12422,-2,0,0,883;9,3,12422,-2,0,0,883;10,2,12541,-2,0,0,883;11,1,12766,-2,0,0,883;12,3,12766,-2,0,0,883;-1,2,-94,-110,0,1,1363,1044,37;1,1,1379,1029,47;2,1,1396,998,59;3,1,1413,888,96;4,1,1429,792,122;5,1,1446,698,146;6,1,1462,590,170;7,1,1478,520,186;8,1,1496,478,196;9,1,1512,442,204;10,1,1530,418,210;11,1,1546,406,216;12,1,1562,400,217;13,1,1621,400,219;14,1,1630,400,221;15,1,1742,402,221;16,1,1750,403,222;17,1,1763,405,223;18,1,1780,410,223;19,1,1795,415,224;20,1,1812,425,225;21,1,1829,431,225;22,1,1847,442,225;23,1,1863,455,222;24,1,1880,470,216;25,1,1896,487,211;26,1,1929,506,206;27,1,1930,523,199;28,1,1946,530,195;29,1,1997,531,194;30,1,2026,531,193;31,1,2029,530,191;32,1,2046,529,189;33,1,2063,527,188;34,1,2080,523,184;35,1,2095,519,182;36,1,2113,517,180;37,1,2130,516,180;38,3,2334,516,180,520;39,4,2480,516,180,520;40,2,2480,516,180,520;41,1,3070,518,178;42,1,3078,522,178;43,1,3086,525,178;44,1,3094,527,178;45,1,3099,531,178;46,1,3108,533,178;47,1,3116,535,178;48,1,3125,539,178;49,1,3133,543,178;50,1,3142,548,178;51,1,3149,554,178;52,1,3157,565,178;53,1,3165,576,179;54,1,3174,592,183;55,1,3181,617,189;56,1,3224,844,245;57,1,3240,964,271;58,1,3257,1092,297;59,1,3273,1194,305;60,1,3290,1278,309;61,1,3307,1350,307;62,1,3324,1418,297;63,1,3340,1464,289;64,1,3357,1506,285;65,1,3373,1544,281;66,1,3390,1590,275;67,1,3407,1626,269;68,1,3423,1648,261;69,1,3440,1652,251;70,1,3456,1654,247;71,1,3629,1653,247;72,1,3637,1649,248;73,1,3646,1649,250;74,1,3654,1649,251;75,1,3670,1651,252;76,1,3687,1657,239;77,1,3704,1664,225;78,1,3721,1672,213;79,1,3737,1677,205;80,1,7228,1617,184;81,1,7244,1537,200;82,1,7260,1405,218;83,1,7278,1159,238;84,1,7294,973,254;85,1,7311,775,262;86,1,7329,557,270;87,1,7345,357,274;88,1,7361,197,284;89,1,7378,83,268;90,1,7395,9,236;91,1,7411,-1,-1;92,1,7606,4,218;93,1,7615,9,219;94,1,7622,21,222;95,1,7630,39,222;96,1,7638,64,224;97,1,7646,94,224;98,1,7654,128,218;99,1,7663,160,216;100,1,7671,192,214;101,1,7678,218,214;102,1,7686,250,214;139,3,8277,480,178,520;140,4,8409,480,178,520;141,2,8409,480,178,520;176,3,10288,510,244,883;177,4,10391,510,244,883;178,2,10391,510,244,883;234,3,20817,406,425,1242;235,4,20936,406,425,1242;236,2,20942,406,425,1242;-1,2,-94,-117,-1,2,-94,-111,-1,2,-94,-109,-1,2,-94,-114,-1,2,-94,-103,3,51;2,6537;3,8264;-1,2,-94,-112,https://www.itv.com/hub/user/signin-1,2,-94,-115,169649,606167,32,0,0,0,775783,20959,0,1665862462846,6,17820,13,237,2970,8,0,20960,652788,1,1,49,951,-446417374,26067385,PiZtE,78068,22,0,-1-1,2,-94,-106,-1,0-1,2,-94,-119,-1-1,2,-94,-122,0,0,0,0,1,0,0-1,2,-94,-123,-1,2,-94,-124,-1,2,-94,-126,-1,2,-94,-127,-1,2,-94,-70,-844419666;149504396;dis;,7;true;true;true;-120;true;24;24;true;false;unspecified-1,2,-94,-80,6510-1,2,-94,-116,332483235-1,2,-94,-118,171916-1,2,-94,-129,,,44f5478a49e419c98b42237f92350e68f367f4784614425eab4847066ff6289d,,,,0-1,2,-94,-121,;8;1;0'
+ }
+ )
+
+ self.account_data = {
+ 'vers': SESS_DATA_VERS,
+ 'refreshed': time.time(),
+ 'itv_session': session_data,
+ 'cookies': {'Itv.Session': build_cookie(session_data)}
+ }
+ except FetchError as e:
+ # Testing showed that itv hub can return various HTTP status codes on a failed sign in attempt.
+ # Sometimes returning a json string containing the reason of failure, sometimes and HTML page.
+ logger.error("Error signing in to ITV account: %r" % e)
+ if isinstance(e, AuthenticationError) or (isinstance(e, HttpError) and e.code in (400, 401, 403)):
+ logger.info("Sign in failed: %r", e)
+ raise AuthenticationError(str(e)) from None
+ else:
+ raise
+ else:
+ logger.info("Sign in successful")
+ self.save_account_data()
+ return True
+
+ def refresh(self):
+ """Refresh tokens.
+ Perform a get request with the current renew token in the param string. ITV hub will
+ return a json formatted string containing a new access token and a new renew-token.
+
+ """
+ logger.debug("Refreshing ITV account tokens...")
+ try:
+ token = self.account_data['itv_session']['refresh_token']
+ url = 'https://auth.prd.user.itv.com/token?grant_type=refresh_token&' \
+ 'token=content_token refresh_token&refresh=' + token
+ # Refresh requests require no authorization header and no cookies at all
+ resp = fetch.get_json(
+ url,
+ headers={'Accept': 'application/vnd.user.auth.v2+json'},
+ timeout=10
+ )
+ new_tokens = resp
+ session_data = self.account_data['itv_session']
+ session_data.update(new_tokens)
+ sess_cookie_str = build_cookie(session_data)
+ logger.debug("New Itv.Session cookie: %s" % sess_cookie_str)
+ self.account_data['cookies']['Itv.Session'] = sess_cookie_str
+ self.account_data['refreshed'] = time.time()
+ self.save_account_data()
+ return True
+ except (KeyError, ValueError, FetchError) as e:
+ logger.warning("Failed to refresh ITVtokens - %s: %s" % (type(e), e))
+ except TypeError:
+ logger.warning("Failed to refresh ITV tokens - No account data present.")
+ return False
+
+ def log_out(self):
+ self.account_data = {}
+ self.save_account_data()
+ return True
+
+
+def build_cookie(session_data):
+ cookiestr = json.dumps({
+ 'sticky': True,
+ 'tokens': {'content': session_data}
+ })
+ return cookiestr
+
+
+_itv_session_obj = None
+
+
+def itv_session():
+ global _itv_session_obj
+ if _itv_session_obj is None:
+ _itv_session_obj = ItvSession()
+ return _itv_session_obj
+
+
+def fetch_authenticated(funct, url, **kwargs):
+ """Call one of the fetch function with user authentication.
+
+ Call the specified function with authentication header and return the result.
+ If the server responds with an authentication error, refresh tokens, or
+ login and try once again.
+
+ To prevent headers argument to turn up as both positional and keyword argument,
+ accept keyword arguments only, apart from the callable and url.
+
+ """
+ account = itv_session()
+ logger.debug("making authenticated request")
+
+ for tries in range(2):
+ try:
+ cookies = kwargs.setdefault('cookies', {})
+ cookies.update(account.cookie)
+ return funct(url=url, **kwargs)
+ except AuthenticationError:
+ if tries == 0:
+ logger.debug("Authentication failed on first attempt")
+ if account.refresh() is False:
+ logger.debug("")
+ from . import settings
+ if not (kodi_utils.show_msg_not_logged_in() and settings.login()):
+ raise
+ else:
+ logger.warning("Authentication failed on second attempt")
+ raise AccessRestrictedError
+
+
+def convert_session_data(acc_data: dict) -> dict:
+ acc_data['vers'] = SESS_DATA_VERS
+ sess_data = acc_data.get('itv_session', '')
+ acc_data['cookies'] = {'Itv.Session': build_cookie(sess_data)}
+ acc_data.pop('passw', None)
+ acc_data.pop('uname', None)
+ return acc_data
diff --git a/plugin.video.viwx/resources/lib/itvx.py b/plugin.video.viwx/resources/lib/itvx.py
new file mode 100644
index 000000000..5bd4e7109
--- /dev/null
+++ b/plugin.video.viwx/resources/lib/itvx.py
@@ -0,0 +1,447 @@
+
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
+import time
+import logging
+
+from datetime import datetime
+import pytz
+import requests
+import xbmc
+
+from codequick.support import logger_id
+
+from . import fetch
+from . import parsex
+from . import cache
+
+from .itv import get_live_schedule
+
+
+logger = logging.getLogger(logger_id + '.itvx')
+
+
+FEATURE_SET = 'hd,progressive,single-track,mpeg-dash,widevine,widevine-download,inband-ttml,hls,aes,inband-webvtt,outband-webvtt,inband-audio-description'
+PLATFORM_TAG = 'mobile'
+
+
+def get_page_data(url, cache_time=None):
+ """Return the json data embedded in a ', html_page, flags=re.DOTALL)
+ if result:
+ json_str = result[1]
+ try:
+ data = json.loads(json_str)
+ return data['props']['pageProps']
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
+ logger.warning("__NEXT_DATA__ in HTML page has unexpected format: %r", e)
+ raise ParseError('Invalid data received')
+ raise ParseError('No data available')
+
+
+def parse_hero_content(hero_data):
+ # noinspection PyBroadException
+ try:
+ item_type = hero_data['contentType']
+ title = hero_data['title']
+ item = {
+ 'label': title,
+ 'art': {'thumb': hero_data['imageTemplate'].format(**IMG_PROPS_THUMB),
+ 'fanart': hero_data['imageTemplate'].format(**IMG_PROPS_FANART)},
+ 'info': {'title': ''.join(('[B][COLOR orange]', title, '[/COLOR][/B]'))}
+
+ }
+ brand_img = item.get('brandImageTemplate')
+
+ if brand_img:
+ item['art']['fanart'] = brand_img.format(**IMG_PROPS_FANART)
+
+ if item_type in ('simulcastspot', 'fastchannelspot'):
+ item['params'] = {'channel': hero_data['channel'], 'url': None}
+ item['info'].update(plot='[B]Watch Live[/B]\n' + hero_data.get('description', ''))
+
+ elif item_type in ('series', 'brand'):
+ item['info'].update(plot=''.join((hero_data.get('ariaLabel', ''), '\n\n', hero_data.get('description'))))
+ item['params'] = {'url': build_url(title, hero_data['encodedProgrammeId']['letterA']),
+ 'series_idx': hero_data.get('series')}
+
+ elif item_type in ('special', 'film'):
+ item['info'].update(plot=''.join(('[B]Watch ',
+ 'FILM' if item_type == 'film' else 'NOW',
+ '[/B]\n',
+ hero_data.get('description'))),
+ duration=utils.duration_2_seconds(hero_data.get('duration')))
+ item['params'] = {'url': build_url(title, hero_data['encodedProgrammeId']['letterA']),
+ 'name': title}
+
+ elif item_type == 'collection':
+ item = parse_item_type_collection(hero_data)
+ info = item['show']['info']
+ info['title'] = ''.join(('[COLOR orange]', info['title'], '[/COLOR]'))
+ return item
+ else:
+ logger.warning("Hero item %s is of unknown type: %s", hero_data['title'], item_type)
+ return None
+ return {'type': item_type, 'show': item}
+ except:
+ logger.warning("Failed to parse hero item '%s':\n", hero_data.get('title','unknown title'), exc_info=True)
+
+
+def parse_slider(slider_name, slider_data):
+ coll_data = slider_data['collection']
+ page_link = coll_data.get('headingLink')
+ base_url = 'https://www.itv.com/watch'
+ if page_link:
+ # Link to the collection's page if available
+ params = {'url': base_url + page_link['href']}
+ else:
+ # Provide the slider name when the collection content is to be obtained from the main page.
+ params = {'slider': slider_name}
+
+ return {'type': 'collection',
+ 'playable': False,
+ 'show': {'label': coll_data['headingTitle'],
+ 'params': params,
+ 'info': {'sorttitle': sort_title(coll_data['headingTitle'])}}}
+
+
+def parse_collection_item(show_data, hide_paid=False):
+ """Parse a show item from a collection page
+
+ Very much like category content, but not quite.
+ """
+ # noinspection PyBroadException
+ try:
+ content_type = show_data.get('contentType') or show_data['type']
+ is_playable = content_type in ('episode', 'film', 'special', 'title')
+ title = show_data['title']
+ content_info = show_data.get('contentInfo', '')
+
+ if content_type == 'collection':
+ return parse_item_type_collection(show_data)
+
+ if show_data.get('isPaid'):
+ if hide_paid:
+ return None
+ plot = premium_plot(show_data['description'])
+ else:
+ plot = show_data['description']
+
+ programme_item = {
+ 'label': title,
+ 'art': {'thumb': show_data['imageTemplate'].format(**IMG_PROPS_THUMB),
+ 'fanart': show_data['imageTemplate'].format(**IMG_PROPS_FANART)},
+ 'info': {'title': title if is_playable else '[B]{}[/B] {}'.format(title, content_info),
+ 'plot': plot,
+ 'sorttitle': sort_title(title)},
+ 'params': {'url': build_url(show_data['titleSlug'],
+ show_data['encodedProgrammeId']['letterA'],
+ show_data.get('encodedEpisodeId', {}).get('letterA'))}
+ }
+
+ if 'FILMS' in show_data['categories']:
+ programme_item['art']['poster'] = show_data['imageTemplate'].format(**IMG_PROPS_POSTER)
+
+ if is_playable:
+ programme_item['info']['duration'] = utils.duration_2_seconds(content_info)
+ return {'playable': is_playable,
+ 'show': programme_item}
+ except Exception:
+ logger.warning("Failed to parse collection_item:\n%s", json.dumps(show_data, indent=4))
+ return None
+
+# noinspection GrazieInspection
+def parse_news_collection_item(news_item, time_zone, time_fmt, hide_paid=False):
+ """Parse data found in news collection and in short news clips from news sub-categories
+
+ """
+ try:
+ if 'encodedProgrammeId' in news_item.keys():
+ # The news item is a 'normal' catchup title. Is usually just the latest ITV news.
+ # Do not use field 'href' as it is known to have non-a-encoded program and episode Id's which doesn't work.
+ url = '/'.join(('https://www.itv.com/watch',
+ news_item['titleSlug'],
+ news_item['encodedProgrammeId']['letterA'],
+ news_item.get('encodedEpisodeId', {}).get('letterA', ''))).rstrip('/')
+ else:
+ # This news item is a 'short item', aka 'news clip'.
+ url = '/'.join(('https://www.itv.com/watch/news', news_item['titleSlug'], news_item['episodeId']))
+
+ # dateTime field occasionally has milliseconds. Strip these when present.
+ item_time = pytz.UTC.localize(utils.strptime(news_item['dateTime'][:19], '%Y-%m-%dT%H:%M:%S'))
+ loc_time = item_time.astimezone(time_zone)
+ title = news_item.get('episodeTitle')
+ plot = '\n'.join((loc_time.strftime(time_fmt), news_item.get('synopsis', title)))
+
+ # Does paid news exists?
+ if news_item.get('isPaid'):
+ if hide_paid:
+ return None
+ plot = premium_plot(plot)
+
+ # TODO: consider adding poster image, but it is not always present.
+ # Add date.
+ return {
+ 'playable': True,
+ 'show': {
+ 'label': title,
+ 'art': {'thumb': news_item['imageUrl'].format(**IMG_PROPS_THUMB)},
+ 'info': {'plot': plot, 'sorttitle': sort_title(title), 'duration': news_item.get('duration')},
+ 'params': {'url': url }
+ }
+ }
+ except Exception:
+ logger.warning("Failed to parse news_collection_item:\n%s", json.dumps(news_item, indent=4))
+ return None
+
+
+def parse_trending_collection_item(trending_item, hide_paid=False):
+ """Parse an item in the collection 'Trending'
+ The only real difference with the regular parse_collection_item() is
+ adding field `contentInfo` to plot and the fact that all items are being
+ treated as playable.
+
+ """
+ try:
+ # No idea if premium content can be trending, but just to be sure.
+ plot = '\n'.join((trending_item['description'], trending_item['contentInfo']))
+ if trending_item.get('isPaid'):
+ if hide_paid:
+ return None
+ plot = premium_plot(plot)
+
+ # NOTE:
+ # Especially titles of type 'special' may lack a field encodedEpisodeID. For those titles it
+ # should not be necessary, but for episodes they are a requirement otherwise the page
+ # will always return the first episode.
+
+ return{
+ 'playable': True,
+ 'show': {
+ 'label': trending_item['title'],
+ 'art': {'thumb': trending_item['imageUrl'].format(**IMG_PROPS_THUMB)},
+ 'info': {'plot': plot, 'sorttitle': sort_title(trending_item['title'])},
+ 'params': {'url': build_url(trending_item['titleSlug'],
+ trending_item['encodedProgrammeId']['letterA'],
+ trending_item.get('encodedEpisodeId', {}).get('letterA'))}
+ }
+ }
+ except Exception:
+ logger.warning("Failed to parse trending_collection_item:\n%s", json.dumps(trending_item, indent=4))
+ return None
+
+
+def parse_category_item(prog, category):
+ # At least all items without an encodedEpisodeId are playable.
+ # Unfortunately there are items that do have an episodeId, but are in fact single
+ # episodes, and thus playable, but there is no reliable way of detecting these,
+ # since category items lack a field like `contentType`.
+ # The previous method of detecting the presence of 'series' in contentInfo proved
+ # to be very unreliable.
+ #
+ # All items with episodeId are returned as series folder, with the odd change some
+ # contain only one item.
+
+ is_playable = prog['encodedEpisodeId']['letterA'] == ''
+ playtime = utils.duration_2_seconds(prog['contentInfo'])
+ title = prog['title']
+
+ if 'FREE' in prog['tier']:
+ plot = prog['description']
+ else:
+ plot = premium_plot(prog['description'])
+
+ programme_item = {
+ 'label': title,
+ 'art': {'thumb': prog['imageTemplate'].format(**IMG_PROPS_THUMB),
+ 'fanart': prog['imageTemplate'].format(**IMG_PROPS_FANART)},
+ 'info': {'title': title if is_playable
+ else '[B]{}[/B] {}'.format(title, prog['contentInfo'] if not playtime else ''),
+ 'plot': plot,
+ 'sorttitle': sort_title(title)},
+ }
+
+ if category == 'films':
+ programme_item['art']['poster'] = prog['imageTemplate'].format(**IMG_PROPS_POSTER)
+
+ if is_playable:
+ programme_item['info']['duration'] = playtime
+ programme_item['params'] = {'url': build_url(title, prog['encodedProgrammeId']['letterA'])}
+ else:
+ programme_item['params'] = {'url': build_url(title,
+ prog['encodedProgrammeId']['letterA'],
+ prog['encodedEpisodeId']['letterA'])}
+ return {'playable': is_playable,
+ 'show': programme_item}
+
+
+def parse_item_type_collection(item_data):
+ """Parse an item of type 'collection' found in heroContent or a collection.
+ The collection items refer to another collection.
+
+ .. note::
+ Only items from heroContent seem to have a field `ctaLabel`.
+
+ """
+ title = item_data['title']
+ item = {
+ 'label': title,
+ 'art': {'thumb': item_data['imageTemplate'].format(**IMG_PROPS_THUMB),
+ 'fanart': item_data['imageTemplate'].format(**IMG_PROPS_FANART)},
+ 'info': {'title': '[B]{}[/B]'.format(title),
+ 'plot': item_data.get('ctaLabel', 'Collection'),
+ 'sorttitle': sort_title(title)},
+ 'params': {'url': '/'.join(('https://www.itv.com/watch/collections',
+ item_data.get('titleSlug', ''),
+ item_data.get('collectionId')))}
+ }
+ return {'type': 'collection', 'playable': False, 'show': item}
+
+
+def parse_episode_title(title_data, brand_fanart=None):
+ """Parse a title from episodes listing"""
+ # Note: episodeTitle may be None
+ title = title_data['episodeTitle'] or title_data['heroCtaLabel']
+ img_url = title_data['image']
+ plot = '\n\n'.join((title_data['longDescription'], title_data['guidance'] or ''))
+ if title_data['premium']:
+ plot = premium_plot(plot)
+
+ episode_nr = title_data.get('episode')
+ if episode_nr and title_data['episodeTitle'] is not None:
+ info_title = '{}. {}'.format(episode_nr, title_data['episodeTitle'])
+ else:
+ info_title = title_data['heroCtaLabel']
+
+ title_obj = {
+ 'label': title,
+ 'art': {'thumb': img_url.format(**IMG_PROPS_THUMB),
+ 'fanart': brand_fanart,
+ # 'poster': img_url.format(**IMG_PROPS_POSTER)
+ },
+ 'info': {'title': info_title,
+ 'plot': plot,
+ 'duration': int(utils.iso_duration_2_seconds(title_data['notFormattedDuration'])),
+ 'date': title_data['dateTime'],
+ 'episode': episode_nr,
+ 'season': title_data.get('series'),
+ 'year': title_data.get('productionYear')},
+ 'params': {'url': title_data['playlistUrl'], 'name': title}
+ }
+
+ return title_obj
+
+
+def parse_legacy_episode_title(title_data, brand_fanart=None):
+ """Parse a title from episodes listing in old format"""
+ # Note: episodeTitle may be None
+ title = title_data['episodeTitle'] or title_data['numberedEpisodeTitle']
+ img_url = title_data['imageUrl']
+ plot = '\n\n'.join((title_data['synopsis'], title_data['guidance'] or ''))
+ if 'PAID' in title_data.get('tier', []):
+ plot = premium_plot(plot)
+
+ title_obj = {
+ 'label': title,
+ 'art': {'thumb': img_url.format(**IMG_PROPS_THUMB),
+ 'fanart': brand_fanart,
+ # 'poster': img_url.format(**IMG_PROPS_POSTER)
+ },
+ 'info': {'title': title_data['numberedEpisodeTitle'],
+ 'plot': plot,
+ 'duration': utils.duration_2_seconds(title_data['duration']),
+ 'date': title_data['broadcastDateTime']},
+ 'params': {'url': title_data['playlistUrl'], 'name': title}
+ }
+ if title_data['titleType'] == 'EPISODE':
+ try:
+ episode_nr = int(title_data['episodeNumber'])
+ except ValueError:
+ episode_nr = None
+ try:
+ series_nr = int(title_data['seriesNumber'])
+ except ValueError:
+ series_nr = None
+ title_obj['info'].update(episode=episode_nr, season=series_nr)
+ return title_obj
+
+
+def parse_search_result(search_data):
+ entity_type = search_data['entityType']
+ result_data = search_data['data']
+ api_episode_id = ''
+ if 'FREE' in result_data['tier']:
+ plot = result_data['synopsis']
+ else:
+ plot = premium_plot(result_data['synopsis'])
+
+ if entity_type == 'programme':
+ prog_name = result_data['programmeTitle']
+ title = '[B]{}[/B] - {} episodes'.format(prog_name, result_data.get('totalAvailableEpisodes', ''))
+ img_url = result_data['latestAvailableEpisode']['imageHref']
+ api_prod_id = result_data['legacyId']['officialFormat']
+
+ elif entity_type == 'special':
+ # A single programme without episodes
+ title = result_data['specialTitle']
+ img_url = result_data['imageHref']
+
+ programme = result_data.get('specialProgramme')
+ if programme:
+ prog_name = result_data['specialProgramme']['programmeTitle']
+ api_prod_id = result_data['specialProgramme']['legacyId']['officialFormat']
+ api_episode_id = result_data['legacyId']['officialFormat']
+ else:
+ prog_name = title
+ api_prod_id = result_data['legacyId']['officialFormat']
+
+ elif entity_type == 'film':
+ prog_name = result_data['filmTitle']
+ title = '[B]Film[/B] - ' + result_data['filmTitle']
+ img_url = result_data['imageHref']
+ api_prod_id = result_data['legacyId']['officialFormat']
+
+ else:
+ logger.warning("Unknown search result item entityType %s", entity_type)
+ return None
+
+ return {
+ 'playable': entity_type != 'programme',
+ 'show': {
+ 'label': prog_name,
+ 'art': {'thumb': img_url.format(**IMG_PROPS_THUMB)},
+ 'info': {'plot': plot,
+ 'title': title},
+ 'params': {'url': build_url(prog_name, api_prod_id.replace('/', 'a'), api_episode_id.replace('/', 'a'))}
+ }
+ }
diff --git a/plugin.video.viwx/resources/lib/settings.py b/plugin.video.viwx/resources/lib/settings.py
new file mode 100644
index 000000000..4bded7f12
--- /dev/null
+++ b/plugin.video.viwx/resources/lib/settings.py
@@ -0,0 +1,73 @@
+
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
+import logging
+
+from codequick import Script
+from codequick.support import addon_data, logger_id
+
+from resources.lib import itv_account
+from resources.lib import kodi_utils
+from resources.lib import addon_log
+from resources.lib import errors
+
+logger = logging.getLogger('.'.join((logger_id, __name__)))
+
+
+@Script.register()
+def login(_=None):
+ """Ask the user to enter credentials and try to sign in to ITVX.
+
+ On failure ask to retry and continue to do so until signin succeeds,
+ or the user selects cancel.
+ """
+ uname = None
+ passw = None
+
+ while True:
+ uname, passw = kodi_utils.ask_credentials(uname, passw)
+ if not all((uname, passw)):
+ logger.info("Entering login credentials canceled by user")
+ return
+ try:
+ itv_account.itv_session().login(uname, passw)
+ kodi_utils.show_login_result(success=True)
+ return
+ except errors.AuthenticationError as e:
+ if not kodi_utils.ask_login_retry(str(e)):
+ logger.info("Login retry canceled by user")
+ return
+
+
+@Script.register()
+def logout(_):
+ # just to provide a route for settings' log out
+ if itv_account.itv_session().log_out():
+ Script.notify(Script.localize(kodi_utils.TXT_ITV_ACCOUNT),
+ Script.localize(kodi_utils.MSG_LOGGED_OUT_SUCCESS),
+ Script.NOTIFY_INFO)
+
+
+@Script.register()
+def change_logger(_):
+ """Callback for settings->generic->log_to.
+ Let the user choose between logging to kodi log, to our own file, or no logging at all.
+
+ """
+ handlers = (addon_log.KodiLogHandler, addon_log.CtFileHandler, addon_log.DummyHandler)
+
+ try:
+ curr_hndlr_idx = handlers.index(type(addon_log.logger.handlers[0]))
+ except (ValueError, IndexError):
+ curr_hndlr_idx = 0
+
+ new_hndlr_idx, handler_name = kodi_utils.ask_log_handler(curr_hndlr_idx)
+ handler_type = handlers[new_hndlr_idx]
+
+ addon_log.set_log_handler(handler_type)
+ addon_data.setSettingString('log-handler', handler_name)
diff --git a/plugin.video.viwx/resources/lib/utils.py b/plugin.video.viwx/resources/lib/utils.py
new file mode 100644
index 000000000..d503d239b
--- /dev/null
+++ b/plugin.video.viwx/resources/lib/utils.py
@@ -0,0 +1,309 @@
+# ----------------------------------------------------------------------------------------------------------------------
+# Copyright (c) 2022-2023 Dimitri Kroon.
+# This file is part of plugin.video.viwx.
+# SPDX-License-Identifier: GPL-2.0-or-later
+# See LICENSE.txt
+# ----------------------------------------------------------------------------------------------------------------------
+
+from __future__ import annotations
+import logging
+import time
+import string
+from datetime import datetime
+
+from xbmcvfs import translatePath
+import xbmcaddon
+
+from codequick.support import logger_id
+
+
+class AddonInfo:
+ def __init__(self):
+ self.initialise()
+
+ # noinspection PyAttributeOutsideInit
+ def initialise(self):
+ self.addon = addon = xbmcaddon.Addon()
+ self.name = addon.getAddonInfo("name")
+ self.id = addon.getAddonInfo("id")
+ self.localise = addon.getLocalizedString
+ self.profile = translatePath(addon.getAddonInfo('profile'))
+
+
+logger = logging.getLogger(logger_id + '.utils')
+addon_info = AddonInfo()
+
+
+def get_os():
+ import platform
+ return platform.system(), platform.machine()
+
+
+def random_string(length: int) -> str:
+ """Return a string of random upper and lowercase charters and numbers"""
+ import random
+ import string
+
+ chars = string.ascii_letters + string.digits
+ result = ''.join(random.choice(chars) for _ in range(length))
+ return result
+
+
+def ttml_to_srt(ttml_data, outfile):
+ """Convert subtitles in XML format to a format that kodi accepts"""
+ from xml.etree import ElementTree
+ import re
+
+ # Get XML namespace
+ match = re.search(r'xmlns="(.*?)" ', ttml_data, re.DOTALL)
+ if match:
+ xmlns = ''.join(('{', match.group(1), '}'))
+ else:
+ xmlns = ''
+
+ FONT_COL_WHITE = ''
+ FONT_END_TAG = '\n'
+
+ root = ElementTree.fromstring(ttml_data)
+
+ dflt_styles = {}
+ path = ''.join(('./', xmlns, 'head', '/', xmlns, 'styling', '/', xmlns, 'style'))
+ styles = root.findall(path)
+ for style_def in styles:
+ style_id = style_def.get(xmlns + 'id')
+ colors = [value for tag, value in style_def.items() if tag.endswith('color')]
+ if colors:
+ col = colors[0]
+ # strip possible alpha value if color is a HTML encoded RBGA value
+ if col.startswith('#'):
+ col = col[:7]
+ dflt_styles[style_id] = ''.join((''))
+
+ body = root.find(xmlns + 'body')
+ if body is None:
+ return
+
+ index = 0
+ # lines = []
+ color_tag = "{http://www.w3.org/ns/ttml#styling}" + 'color'
+
+ for paragraph in body.iter(xmlns + 'p'):
+ index += 1
+
+ t_start = paragraph.get('begin')
+ t_end = paragraph.get('end')
+ if not (t_start and t_end):
+ continue
+ outfile.write(str(index) + '\n')
+ # convert xml time format: begin="00:03:33:14" end="00:03:36:06"
+ # to srt format: 00:03:33,140 --> 00:03:36,060
+ outfile.write(''.join((t_start[0:-3], ',', t_start[-2:], '0', ' --> ', t_end[0:-3], ',', t_end[-2:], '0\n')))
+
+ p_style = paragraph.get('style')
+ p_col = dflt_styles.get(p_style, FONT_COL_WHITE)
+ if paragraph.text:
+ outfile.write(''.join((p_col, paragraph.text, FONT_END_TAG)))
+ for el in paragraph:
+ if el.tag.endswith('span') and el.text:
+ col = el.get(color_tag, 'white')
+ # col = [v for k, v in el.items() if k.endswith('color')]
+ # if col:
+ outfile.write(''.join(('', el.text, FONT_END_TAG)))
+ # else:
+ # lines.append(''.join((FONT_COL_WHITE, el.text, FONT_END_TAG)))
+ if el.tail:
+ outfile.write(''.join((p_col, el.tail, FONT_END_TAG)))
+ outfile.write('\n')
+
+
+def vtt_to_srt(vtt_doc: str, colourize=True) -> str:
+ """Convert a string containing subtitles in vtt format into a format kodi accepts.
+
+ Very simple converter that does not expect much styling, position, etc. and tries
+ to ignore most fancy vtt stuff. But seems to be enough for most itv subtitles.
+
+ All styling, except bold, italic, underline and colour in the cue payload is
+ removed, as well as position information.
+
+ """
+ from io import StringIO
+ import re
+
+ # Match a line that start with cue timings. Accept timings with or without hours.
+ regex = re.compile(r'(\d{2})?:?(\d{2}:\d{2})\.(\d{3}) +--> +(\d{2})?:?(\d{2}:\d{2})\.(\d{3})')
+
+ # Convert new lines conform WebVTT specs
+ vtt_doc = vtt_doc.replace('\r\n', '\n')
+ vtt_doc = vtt_doc.replace('\r', '\n')
+
+ # Split the document into blocks that are separated by an empty line.
+ vtt_blocks = vtt_doc.split('\n\n')
+ seq_nr = 0
+
+ with StringIO() as f:
+ for block in vtt_blocks:
+ lines = iter(block.split('\n'))
+
+ # Find cue timings, ignore all cue settings.
+ try:
+ line = next(lines)
+ timings_match = regex.match(line)
+ if not timings_match:
+ # The first line may be a cue identifier
+ line = next(lines)
+ timings_match = regex.match(line)
+ if not timings_match:
+ # Also no timings in the second line: this is not a cue block
+ continue
+ except StopIteration:
+ # Not enough lines to find timings: this is not a cue block
+ continue
+
+ # Write newline and sequence number
+ seq_nr += 1
+ f.write('\n{}\n'.format(seq_nr))
+ # Write cue timings, add "00" for missing hours.
+ f.write('{}:{},{} --> {}:{},{}\n'.format(*timings_match.groups('00')))
+ # Write out the lines of the cue payload
+ for line in lines:
+ f.write(line + '\n')
+
+ srt_doc = f.getvalue()
+
+ if colourize:
+ # Remove any markup tag other than the supported bold, italic underline and colour.
+ srt_doc = re.sub(r'<([^biuc]).*?>(.*?)\1.*?>', r'\2', srt_doc)
+
+ # convert color tags, accept RGB(A) colours and named colours supported by Kodi.
+ def sub_color_tags(match):
+ colour = match[1]
+ if colour in ('white', 'yellow', 'green', 'cyan', 'red'):
+ # Named colours
+ return '{}'.format(colour, match[2])
+ elif colour.startswith('color'):
+ # RBG colour, ensure to strip the alpha channel if present.
+ result = '{}'.format(colour[5:11], match[2])
+ return result
+ else:
+ logger.debug("Unsupported colour '%s' in vtt file", colour)
+ return match[2]
+
+ srt_doc = re.sub(r'(.*?)', sub_color_tags, srt_doc)
+ else:
+ # Remove any markup tag other than the supported bold, italic underline.
+ srt_doc = re.sub(r'<([^biu]).*?>(.*?)\1.*?>', r'\2', srt_doc)
+ return srt_doc
+
+
+def duration_2_seconds(duration: str) -> int | None:
+ """Convert a string containing duration in various formats to the corresponding number of seconds.
+
+ supported formats:
+
+ * '62' - single number of minutes
+ * '1,32 hrs' - hours as float
+ * '78 min' - number of minutes as integer
+ * '1h 35m' - hours and minutes, where both hours and minutes are optional.
+ * 'PT1H32M' - ISO 8601 duration.
+
+ """
+
+ if not duration:
+ return None
+
+ if duration.startswith("P"):
+ return int(iso_duration_2_seconds(duration))
+
+ hours = 0
+ minutes = 0
+
+ try:
+ splits = duration.split()
+ if len(splits) == 2:
+ # format '62 min'
+ if splits[1] == 'min':
+ return int(splits[0]) * 60
+ if splits[1] == 'hrs':
+ # format '1.56 hrs'
+ return int(float(splits[0]) * 3600)
+
+ for t_str in splits:
+ if t_str.endswith('h'):
+ # format '2h 15m' or '2h'
+ hours = int(t_str[:-1])
+ elif t_str.endswith('m'):
+ minutes = int(t_str[:-1])
+ elif len(splits) == 1:
+ # format '62'
+ minutes = int(t_str)
+
+ return int(hours) * 3600 + int(minutes) * 60
+
+ except (ValueError, AttributeError, IndexError):
+ return None
+
+
+def iso_duration_2_seconds(iso_str: str):
+ """Convert an ISO 8601 duration string into seconds.
+
+ Simple parser to match durations found in films and tv episodes.
+ Handles only hours, minutes and seconds.
+
+ """
+ if len(iso_str) > 3:
+ import re
+ match = re.match(r'^PT(?:([\d.]+)H)?(?:([\d.]+)M)?(?:([\d.]+)S)?$', iso_str)
+ if match:
+ hours, minutes, seconds = match.groups(default=0)
+ try:
+ return float(hours) * 3600 + float(minutes) * 60 + float(seconds)
+ except ValueError:
+ pass
+
+ logger.warning("Invalid ISO8601 duration: '%s'", iso_str)
+ return None
+
+
+def reformat_date(date_string: str, old_format: str, new_format: str):
+ """Take a string containing a datetime in a particular format and
+ convert it into another format.
+
+ Usually used to convert datetime strings obtained from a website into a nice readable format.
+
+ """
+ dt = datetime(*(time.strptime(date_string, old_format)[0:6]))
+ return dt.strftime(new_format)
+
+
+def strptime(dt_str: str, format: str):
+ """A bug free alternative to `datetime.datetime.strptime(...)`"""
+ return datetime(*(time.strptime(dt_str, format)[0:6]))
+
+
+def paginate(items: list, page_nr: int, page_len: int, merge_count: int = 5):
+ """Return a subset of the list.
+
+ Prevent last pages of `merge_count` or fewer items by adding them to the previous page.
+ """
+ start = page_nr * page_len
+ end = start + page_len
+ if end + merge_count < len(items):
+ return items[start:end], page_nr + 1
+ else:
+ return items[start:end + merge_count], None
+
+
+def list_start_chars(items: list):
+ """Return a list of all starting character present in the sorttitles in the list `items`.
+
+ Used to create an A-Z listing to subdivide long lists of items, but only list those
+ characters that have actual items.
+
+ """
+ start_chars = set(item['show']['info']['sorttitle'][0].upper() for item in items)
+ az_chars = list(string.ascii_uppercase)
+ char_list = sorted(start_chars.intersection(az_chars))
+ if start_chars.difference(char_list):
+ # Anything not a letter
+ char_list.append('0-9')
+ return char_list
diff --git a/plugin.video.viwx/resources/settings.xml b/plugin.video.viwx/resources/settings.xml
new file mode 100644
index 000000000..e171e0a9b
--- /dev/null
+++ b/plugin.video.viwx/resources/settings.xml
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+ 0
+ false
+
+
+
+ 0
+ true
+
+ true
+
+
+
+
+ 0
+ false
+
+
+
+ 1
+ 250
+
+ 150
+ 10
+ 1000
+
+
+ false
+
+
+
+ 1
+ 60
+
+ 30
+ 10
+ 400
+
+
+ false
+
+
+
+
+
+ 0
+ false
+
+
+
+
+
+ 2
+ Kodi log
+
+ false
+
+
+ RunPlugin(plugin://plugin.video.viwx/resources/lib/settings/change_logger)
+
+
+
+ 3
+ 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ RunPlugin(plugin://$ID/resources/lib/settings/login)
+
+ true
+
+
+
+ 0
+ RunPlugin(plugin://$ID/resources/lib/settings/logout)
+
+
+
+ 0
+ false
+
+
+
+
+
+