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 + https://github.com/dimkroon/itvx-for-kodi + + 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]).*?>(.*?)', 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]).*?>(.*?)', 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 + + + + +
+