diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..584cc4c --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,12 @@ +coverage: + range: 50..100 + round: nearest + status: + project: + default: + target: 75% + threshold: 6% + patch: false +comment: false +ignore: +- "test" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9638a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +*.pyc +*.pyo +.DS* +.pylint_rc +/.idea +/.project +/.pydevproject +/.settings +Thumbs.db +*~ +.cache + +.coverage +.tox/ +test/userdata/addon_settings.json +test/userdata/credentials.json +test/userdata/temp +test/userdata/token.json +test/userdata/cache +test/userdata/addon_data +test/userdata/tokens +test/cdm diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..e29df18 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,15 @@ +[MESSAGES CONTROL] +disable= + bad-option-value, + duplicate-code, + fixme, + import-outside-toplevel, + invalid-name, + line-too-long, + old-style-class, + too-few-public-methods, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-locals, + too-many-public-methods, diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a1f9e9c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,27 @@ +language: python + +python: + - '2.7' + - '3.5' + - '3.6' + - '3.7' + - '3.8' + +os: linux + +env: + PYTHONPATH: :test + PYTHONIOENCODING: utf-8 + +install: + - pip install -r requirements.txt + +script: + - make check-pylint + - make check-tox + - make check-translations + - if [ "$TRAVIS_PYTHON_VERSION" == "3.8" ]; then pip install kodi-addon-checker && make check-addon; fi + - make test + +after_success: + - codecov diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9cecc1d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. 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 +them 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 prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. 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. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey 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; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If 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 convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + 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. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +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. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ac0f952 --- /dev/null +++ b/Makefile @@ -0,0 +1,69 @@ +ENVS = py27,py36,py37 +export PYTHONPATH := $(CURDIR):$(CURDIR)/test + +# Collect information to build as sensible package name +name = plugin.video.viervijfzes +version = $(shell xmllint --xpath 'string(/addon/@version)' addon.xml) +git_branch = $(shell git rev-parse --abbrev-ref HEAD) +git_hash = $(shell git rev-parse --short HEAD) +zip_name = $(name)-$(version)-$(git_branch)-$(git_hash).zip +include_files = addon.py addon.xml CHANGELOG.md LICENSE README.md resources/ +include_paths = $(patsubst %,$(name)/%,$(include_files)) +exclude_files = \*.new \*.orig \*.pyc \*.pyo + +all: check test build +zip: build + +check: check-pylint check-tox check-translations + +check-pylint: + @echo ">>> Running pylint checks" + @pylint *.py resources/ test/ + +check-tox: + @echo ">>> Running tox checks" + @tox -q + +check-translations: + @echo ">>> Running translation checks" + @msgcmp resources/language/resource.language.nl_nl/strings.po resources/language/resource.language.en_gb/strings.po + +check-addon: clean build + @echo ">>> Running addon checks" + $(eval TMPDIR := $(shell mktemp -d)) + @unzip ../${zip_name} -d ${TMPDIR} + cd ${TMPDIR} && kodi-addon-checker --branch=leia + @rm -rf ${TMPDIR} + +test: test-unit + +test-unit: + @echo ">>> Running unit tests" +ifdef TRAVIS_JOB_ID + @coverage run -m unittest discover +else + @python -m unittest discover -v -b -f +endif + +clean: + @find . -name '*.pyc' -type f -delete + @find . -name '*.pyo' -type f -delete + @find . -name '__pycache__' -type d -delete + @rm -rf .pytest_cache/ .tox/ test/cdm test/userdata/temp + @rm -f *.log .coverage + +build: clean + @echo ">>> Building package" + @rm -f ../$(zip_name) + cd ..; zip -r $(zip_name) $(include_paths) -x $(exclude_files) + @echo "Successfully wrote package as: ../$(zip_name)" + +release: build + rm -rf ../repo-plugins/plugin.video.viervijfzes/* + unzip ../$(zip_name) -d ../repo-plugins/ + +run: + @echo ">>> Run CLI" + python test/run.py / + +.PHONY: check test diff --git a/addon.py b/addon.py new file mode 100644 index 0000000..d63bec4 --- /dev/null +++ b/addon.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""Addon entry point""" + +from __future__ import absolute_import, division, unicode_literals + +import xbmcaddon + +from resources.lib import kodiutils + +kodiutils.ADDON = xbmcaddon.Addon() + +if __name__ == '__main__': + from sys import argv + from resources.lib.addon import run + + run(argv) diff --git a/addon.xml b/addon.xml new file mode 100644 index 0000000..44993bd --- /dev/null +++ b/addon.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + video + + + Watch content from VIER, VIJF and ZES. + all + GPL-3.0 + v0.1.0 +- First release + + https://github.com/add-ons/plugin.video.viervijfzes + + resources/icon.png + resources/fanart.png + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a1466dd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +codecov +git+git://github.com/emilsvennesson/script.module.inputstreamhelper.git@master#egg=inputstreamhelper +polib +pylint +python-dateutil +requests +git+git://github.com/dagwieers/kodi-plugin-routing.git@setup#egg=routing +tox-travis +six \ No newline at end of file diff --git a/resources/__init__.py b/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 0000000..4eca69c --- /dev/null +++ b/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,135 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +### MENUS +msgctxt "#30001" +msgid "A-Z" +msgstr "" + +msgctxt "#30002" +msgid "Alphabetically sorted list of programs" +msgstr "" + +msgctxt "#30007" +msgid "Channels" +msgstr "" + +msgctxt "#30008" +msgid "Show channel based overview" +msgstr "" + +msgctxt "#30009" +msgid "Search" +msgstr "" + +msgctxt "#30010" +msgid "Search trough the catalogue" +msgstr "" + +msgctxt "#30013" +msgid "TV guide" +msgstr "" + +msgctxt "#30014" +msgid "Browse the TV Guide" +msgstr "" + + +### SUBMENUS +msgctxt "#30053" +msgid "TV Guide for [B]{channel}[/B]" +msgstr "" + +msgctxt "#30054" +msgid "Browse the TV Guide for [B]{channel}[/B]" +msgstr "" + +msgctxt "#30055" +msgid "Catalog for [B]{channel}[/B]" +msgstr "" + +msgctxt "#30056" +msgid "Browse the Catalog for [B]{channel}[/B]" +msgstr "" + + +### CONTEXT MENU +msgctxt "#30102" +msgid "Go to Program" +msgstr "" + + +### CODE +msgctxt "#30204" +msgid "All seasons" +msgstr "" + +msgctxt "#30205" +msgid "Season {season}" +msgstr "" + +msgctxt "#30206" +msgid "Watch [B]{label}[/B] on YouTube" +msgstr "" + + +### Dates +msgctxt "#30301" +msgid "Yesterday" +msgstr "" + +msgctxt "#30302" +msgid "Today" +msgstr "" + +msgctxt "#30303" +msgid "Tomorrow" +msgstr "" + + +### MESSAGES +msgctxt "#30709" +msgid "Geo-blocked video" +msgstr "" + +msgctxt "#30710" +msgid "This video is geo-blocked and can't be played from your location." +msgstr "" + +msgctxt "#30711" +msgid "Unavailable video" +msgstr "" + +msgctxt "#30712" +msgid "The video is unavailable and can't be played right now." +msgstr "" + +msgctxt "#30713" +msgid "The requested video was not found in the guide." +msgstr "" + +msgctxt "#30717" +msgid "This program is not available in the Vier/Vijf/Zes catalogue." +msgstr "" + + +### SETTINGS +msgctxt "#30800" +msgid "Credentials" +msgstr "" + +msgctxt "#30801" +msgid "Vier/Vijf/Zes credentials" +msgstr "" + +msgctxt "#30803" +msgid "Email address" +msgstr "" + +msgctxt "#30805" +msgid "Password" +msgstr "" diff --git a/resources/language/resource.language.nl_nl/strings.po b/resources/language/resource.language.nl_nl/strings.po new file mode 100644 index 0000000..585ae26 --- /dev/null +++ b/resources/language/resource.language.nl_nl/strings.po @@ -0,0 +1,136 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: nl\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +### MENUS +msgctxt "#30001" +msgid "A-Z" +msgstr "A-Z" + +msgctxt "#30002" +msgid "Alphabetically sorted list of programs" +msgstr "Volledige lijst van programma's" + +msgctxt "#30007" +msgid "Channels" +msgstr "Kanalen" + +msgctxt "#30008" +msgid "Show channel based overview" +msgstr "Toon een overzicht per tv-kanaal" + +msgctxt "#30009" +msgid "Search" +msgstr "Zoeken" + +msgctxt "#30010" +msgid "Search trough the catalogue" +msgstr "Doorzoek de catalogus" + +msgctxt "#30013" +msgid "TV guide" +msgstr "Tv-gids" + +msgctxt "#30014" +msgid "Browse the TV Guide" +msgstr "Doorblader de tv-gids" + + +### SUBMENUS +msgctxt "#30053" +msgid "TV Guide for [B]{channel}[/B]" +msgstr "TV-gids voor [B]{channel}[/B]" + +msgctxt "#30054" +msgid "Browse the TV Guide for [B]{channel}[/B]" +msgstr "Doorblader de TV-gids voor [B]{channel}[/B]" + +msgctxt "#30055" +msgid "Catalog for [B]{channel}[/B]" +msgstr "Catalogus voor [B]{channel}[/B]" + +msgctxt "#30056" +msgid "Browse the Catalog for [B]{channel}[/B]" +msgstr "Doorblader de catalogus voor [B]{channel}[/B]" + + +### CONTEXT MENU +msgctxt "#30102" +msgid "Go to Program" +msgstr "Ga naar programma" + + +### CODE +msgctxt "#30204" +msgid "All seasons" +msgstr "Alle seizoenen" + +msgctxt "#30205" +msgid "Season {season}" +msgstr "Seizoen {season}" + +msgctxt "#30206" +msgid "Watch [B]{label}[/B] on YouTube" +msgstr "Bekijk [B]{label}[/B] op YouTube" + + +### Dates +msgctxt "#30301" +msgid "Yesterday" +msgstr "Gisteren" + +msgctxt "#30302" +msgid "Today" +msgstr "Vandaag" + +msgctxt "#30303" +msgid "Tomorrow" +msgstr "Morgen" + + +### MESSAGES +msgctxt "#30709" +msgid "Geo-blocked video" +msgstr "Video is geografisch geblokkeerd" + +msgctxt "#30710" +msgid "This video is geo-blocked and can't be played from your location." +msgstr "Deze video is geografisch geblokkeerd en kan niet worden afgespeeld vanaf je locatie." + +msgctxt "#30711" +msgid "Unavailable video" +msgstr "Onbeschikbare video" + +msgctxt "#30712" +msgid "The video is unavailable and can't be played right now." +msgstr "Deze video is niet beschikbaar en kan nu niet worden afgespeeld." + +msgctxt "#30713" +msgid "The requested video was not found in the guide." +msgstr "De gevraagde video werd niet gevonden in de tv-gids." + +msgctxt "#30717" +msgid "This program is not available in the Vier/Vijf/Zes catalogue." +msgstr "Dit programma is niet beschikbaar in de Vier/Vijf/Zes catalogus." + + +### SETTINGS +msgctxt "#30800" +msgid "Credentials" +msgstr "Inloggegevens" + +msgctxt "#30801" +msgid "Vier/Vijf/Zes credentials" +msgstr "Vier/Vijf/Zes inloggegevens" + +msgctxt "#30803" +msgid "Email address" +msgstr "E-mailadres" + +msgctxt "#30805" +msgid "Password" +msgstr "Wachtwoord" diff --git a/resources/lib/__init__.py b/resources/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/lib/addon.py b/resources/lib/addon.py new file mode 100644 index 0000000..a8fab98 --- /dev/null +++ b/resources/lib/addon.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +""" Addon code """ + +from __future__ import absolute_import, division, unicode_literals + +from routing import Plugin + +from resources.lib import kodilogging + +kodilogging.config() +routing = Plugin() + + +@routing.route('/') +def show_main_menu(): + """ Show the main menu """ + from resources.lib.modules.menu import Menu + Menu().show_mainmenu() + + +@routing.route('/channels') +def show_channels(): + """ Shows Live TV channels """ + from resources.lib.modules.channels import Channels + Channels().show_channels() + + +@routing.route('/channels/') +def show_channel_menu(channel): + """ Shows Live TV channels """ + from resources.lib.modules.channels import Channels + Channels().show_channel_menu(channel) + + +@routing.route('/tvguide/channel/') +def show_tvguide_channel(channel): + """ Shows the dates in the tv guide """ + from resources.lib.modules.tvguide import TvGuide + TvGuide().show_tvguide_channel(channel) + + +@routing.route('/tvguide/channel//') +def show_tvguide_detail(channel=None, date=None): + """ Shows the programs of a specific date in the tv guide """ + from resources.lib.modules.tvguide import TvGuide + TvGuide().show_tvguide_detail(channel, date) + + +@routing.route('/catalog') +def show_catalog(): + """ Show the catalog """ + from resources.lib.modules.catalog import Catalog + Catalog().show_catalog() + + +@routing.route('/catalog/by-channel/') +def show_catalog_channel(channel): + """ Show a category in the catalog """ + from resources.lib.modules.catalog import Catalog + Catalog().show_catalog_channel(channel) + + +@routing.route('/catalog/program//') +def show_catalog_program(channel, program): + """ Show a program from the catalog """ + from resources.lib.modules.catalog import Catalog + Catalog().show_program(channel, program) + + +@routing.route('/program/program///') +def show_catalog_program_season(channel, program, season): + """ Show a program from the catalog """ + from resources.lib.modules.catalog import Catalog + Catalog().show_program_season(channel, program, int(season)) + + +@routing.route('/search') +@routing.route('/search/') +def show_search(query=None): + """ Shows the search dialog """ + from resources.lib.modules.search import Search + Search().show_search(query) + + +@routing.route('/play/catalog//') +def play(channel, uuid): + """ Play the requested item """ + from resources.lib.modules.player import Player + Player().play(channel, uuid) + + +@routing.route('/play/page//') +def play_from_page(channel, page): + """ Play the requested item """ + try: # Python 3 + from urllib.parse import unquote + except ImportError: # Python 2 + from urllib import unquote + + from resources.lib.modules.player import Player + Player().play_from_page(channel, unquote(page)) + + +def run(params): + """ Run the routing plugin """ + routing.run(params) diff --git a/resources/lib/kodilogging.py b/resources/lib/kodilogging.py new file mode 100644 index 0000000..bc842e3 --- /dev/null +++ b/resources/lib/kodilogging.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""Log handler for Kodi""" + +from __future__ import unicode_literals + +import logging + +import xbmc +import xbmcaddon + + +class KodiLogHandler(logging.StreamHandler): + """ A log handler for Kodi """ + + def __init__(self): + logging.StreamHandler.__init__(self) + addon_id = xbmcaddon.Addon().getAddonInfo("id") + formatter = logging.Formatter("[{}] [%(name)s] %(message)s".format(addon_id)) + self.setFormatter(formatter) + + def emit(self, record): + """ Emit a log message """ + levels = { + logging.CRITICAL: xbmc.LOGFATAL, + logging.ERROR: xbmc.LOGERROR, + logging.WARNING: xbmc.LOGWARNING, + logging.INFO: xbmc.LOGINFO, + logging.DEBUG: xbmc.LOGDEBUG, + logging.NOTSET: xbmc.LOGNONE, + } + try: + xbmc.log(self.format(record), levels[record.levelno]) + except UnicodeEncodeError: + xbmc.log(self.format(record).encode('utf-8', 'ignore'), levels[record.levelno]) + + def flush(self): + """ Flush the messages """ + + +def config(): + """ Setup the logger with this handler """ + logger = logging.getLogger() + logger.addHandler(KodiLogHandler()) + logger.setLevel(logging.DEBUG) diff --git a/resources/lib/kodiutils.py b/resources/lib/kodiutils.py new file mode 100644 index 0000000..5e6ffed --- /dev/null +++ b/resources/lib/kodiutils.py @@ -0,0 +1,544 @@ +# -*- coding: utf-8 -*- +"""All functionality that requires Kodi imports""" + +from __future__ import absolute_import, division, unicode_literals + +import logging +from contextlib import contextmanager + +import xbmc +import xbmcaddon +import xbmcgui +import xbmcplugin + +ADDON = xbmcaddon.Addon() + +SORT_METHODS = dict( + unsorted=xbmcplugin.SORT_METHOD_UNSORTED, + label=xbmcplugin.SORT_METHOD_LABEL_IGNORE_FOLDERS, + episode=xbmcplugin.SORT_METHOD_EPISODE, + duration=xbmcplugin.SORT_METHOD_DURATION, + year=xbmcplugin.SORT_METHOD_VIDEO_YEAR, + date=xbmcplugin.SORT_METHOD_DATE, +) +DEFAULT_SORT_METHODS = [ + 'unsorted', 'label' +] + +_LOGGER = logging.getLogger('kodiutils') + + +class TitleItem: + """ This helper object holds all information to be used with Kodi xbmc's ListItem object """ + + def __init__(self, title, path=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None, context_menu=None, subtitles_path=None, + is_playable=False): + """ The constructor for the TitleItem class + :type title: str + :type path: str + :type art_dict: dict + :type info_dict: dict + :type prop_dict: dict + :type stream_dict: dict + :type context_menu: list[tuple[str, str]] + :type subtitles_path: list[str] + :type is_playable: bool + """ + self.title = title + self.path = path + self.art_dict = art_dict + self.info_dict = info_dict + self.stream_dict = stream_dict + self.prop_dict = prop_dict + self.context_menu = context_menu + self.subtitles_path = subtitles_path + self.is_playable = is_playable + + def __repr__(self): + return "%r" % self.__dict__ + + +class SafeDict(dict): + """A safe dictionary implementation that does not break down on missing keys""" + + def __missing__(self, key): + """Replace missing keys with the original placeholder""" + return '{' + key + '}' + + +def to_unicode(text, encoding='utf-8', errors='strict'): + """Force text to unicode""" + if isinstance(text, bytes): + return text.decode(encoding, errors=errors) + return text + + +def from_unicode(text, encoding='utf-8', errors='strict'): + """Force unicode to text""" + import sys + if sys.version_info.major == 2 and isinstance(text, unicode): # noqa: F821; pylint: disable=undefined-variable + return text.encode(encoding, errors) + return text + + +def addon_icon(): + """Cache and return add-on icon""" + return get_addon_info('icon') + + +def addon_id(): + """Cache and return add-on ID""" + return get_addon_info('id') + + +def addon_fanart(): + """Cache and return add-on fanart""" + return get_addon_info('fanart') + + +def addon_name(): + """Cache and return add-on name""" + return get_addon_info('name') + + +def addon_path(): + """Cache and return add-on path""" + return get_addon_info('path') + + +def addon_profile(): + """Cache and return add-on profile""" + return to_unicode(xbmc.translatePath(ADDON.getAddonInfo('profile'))) + + +def url_for(name, *args, **kwargs): + """Wrapper for routing.url_for() to lookup by name""" + from resources.lib import addon + return addon.routing.url_for(getattr(addon, name), *args, **kwargs) + + +def show_listing(title_items, category=None, sort=None, content=None, cache=True): + """ Show a virtual directory in Kodi """ + from resources.lib import addon + + if content: + # content is one of: files, songs, artists, albums, movies, tvshows, episodes, musicvideos, videos, images, games + xbmcplugin.setContent(addon.routing.handle, content=content) + + # Jump through hoops to get a stable breadcrumbs implementation + category_label = '' + if category: + if not content: + category_label = addon_name() + ' / ' + if isinstance(category, int): + category_label += localize(category) + else: + category_label += category + elif not content: + category_label = addon_name() + + xbmcplugin.setPluginCategory(handle=addon.routing.handle, category=category_label) + + # Add all sort methods to GUI (start with preferred) + if sort is None: + sort = DEFAULT_SORT_METHODS + elif not isinstance(sort, list): + sort = [sort] + DEFAULT_SORT_METHODS + + for key in sort: + xbmcplugin.addSortMethod(handle=addon.routing.handle, sortMethod=SORT_METHODS[key]) + + # Add the listings + listing = [] + for title_item in title_items: + # Three options: + # - item is a virtual directory/folder (not playable, path) + # - item is a playable file (playable, path) + # - item is non-actionable item (not playable, no path) + is_folder = bool(not title_item.is_playable and title_item.path) + is_playable = bool(title_item.is_playable and title_item.path) + + list_item = xbmcgui.ListItem(label=title_item.title, path=title_item.path) + + if title_item.prop_dict: + list_item.setProperties(title_item.prop_dict) + list_item.setProperty(key='IsPlayable', value='true' if is_playable else 'false') + + list_item.setIsFolder(is_folder) + + if title_item.art_dict: + list_item.setArt(title_item.art_dict) + + if title_item.info_dict: + # type is one of: video, music, pictures, game + list_item.setInfo(type='video', infoLabels=title_item.info_dict) + + if title_item.stream_dict: + # type is one of: video, audio, subtitle + list_item.addStreamInfo('video', title_item.stream_dict) + + if title_item.context_menu: + list_item.addContextMenuItems(title_item.context_menu) + + is_folder = bool(not title_item.is_playable and title_item.path) + url = title_item.path if title_item.path else None + listing.append((url, list_item, is_folder)) + + succeeded = xbmcplugin.addDirectoryItems(addon.routing.handle, listing, len(listing)) + xbmcplugin.endOfDirectory(addon.routing.handle, succeeded, cacheToDisc=cache) + + +def play(stream, title=None, art_dict=None, info_dict=None, prop_dict=None): + """Play the given stream""" + from resources.lib.addon import routing + + play_item = xbmcgui.ListItem(label=title, path=stream) + if art_dict: + play_item.setArt(art_dict) + if info_dict: + play_item.setInfo(type='video', infoLabels=info_dict) + if prop_dict: + play_item.setProperties(prop_dict) + + xbmcplugin.setResolvedUrl(routing.handle, True, listitem=play_item) + + +def get_search_string(heading='', message=''): + """ Ask the user for a search string """ + search_string = None + keyboard = xbmc.Keyboard(message, heading) + keyboard.doModal() + if keyboard.isConfirmed(): + search_string = to_unicode(keyboard.getText()) + return search_string + + +def ok_dialog(heading='', message=''): + """Show Kodi's OK dialog""" + from xbmcgui import Dialog + if not heading: + heading = addon_name() + return Dialog().ok(heading=heading, line1=message) + + +def notification(heading='', message='', icon='info', time=4000): + """Show a Kodi notification""" + from xbmcgui import Dialog + if not heading: + heading = addon_name() + if not icon: + icon = addon_icon() + Dialog().notification(heading=heading, message=message, icon=icon, time=time) + + +def multiselect(heading='', options=None, autoclose=0, preselect=None, use_details=False): + """Show a Kodi multi-select dialog""" + from xbmcgui import Dialog + if not heading: + heading = addon_name() + return Dialog().multiselect(heading=heading, options=options, autoclose=autoclose, preselect=preselect, useDetails=use_details) + + +def progress(heading='', message=''): + """ Show a Kodi progress dialog """ + from xbmcgui import DialogProgress + if not heading: + heading = ADDON.getAddonInfo('name') + dialog_progress = DialogProgress() + dialog_progress.create(heading=heading, line1=message) + return dialog_progress + + +def set_locale(): + """Load the proper locale for date strings, only once""" + if hasattr(set_locale, 'cached'): + return getattr(set_locale, 'cached') + from locale import Error, LC_ALL, setlocale + locale_lang = get_global_setting('locale.language').split('.')[-1] + locale_lang = locale_lang[:-2] + locale_lang[-2:].upper() + # NOTE: setlocale() only works if the platform supports the Kodi configured locale + try: + setlocale(LC_ALL, locale_lang) + except (Error, ValueError) as exc: + if locale_lang != 'en_GB': + _LOGGER.debug("Your system does not support locale '{locale}': {error}", locale=locale_lang, error=exc) + set_locale.cached = False + return False + set_locale.cached = True + return True + + +def localize(string_id, **kwargs): + """Return the translated string from the .po language files, optionally translating variables""" + if kwargs: + from string import Formatter + return Formatter().vformat(ADDON.getLocalizedString(string_id), (), SafeDict(**kwargs)) + return ADDON.getLocalizedString(string_id) + + +def get_setting(key, default=None): + """Get an add-on setting as string""" + try: + value = to_unicode(ADDON.getSetting(key)) + except RuntimeError: # Occurs when the add-on is disabled + return default + if value == '' and default is not None: + return default + return value + + +def get_setting_bool(key, default=None): + """Get an add-on setting as boolean""" + try: + return ADDON.getSettingBool(key) + except (AttributeError, TypeError): # On Krypton or older, or when not a boolean + value = get_setting(key, default) + if value not in ('false', 'true'): + return default + return bool(value == 'true') + except RuntimeError: # Occurs when the add-on is disabled + return default + + +def get_setting_int(key, default=None): + """Get an add-on setting as integer""" + try: + return ADDON.getSettingInt(key) + except (AttributeError, TypeError): # On Krypton or older, or when not an integer + value = get_setting(key, default) + try: + return int(value) + except ValueError: + return default + except RuntimeError: # Occurs when the add-on is disabled + return default + + +def get_setting_float(key, default=None): + """Get an add-on setting""" + try: + return ADDON.getSettingNumber(key) + except (AttributeError, TypeError): # On Krypton or older, or when not a float + value = get_setting(key, default) + try: + return float(value) + except ValueError: + return default + except RuntimeError: # Occurs when the add-on is disabled + return default + + +def set_setting(key, value): + """Set an add-on setting""" + return ADDON.setSetting(key, from_unicode(str(value))) + + +def set_setting_bool(key, value): + """Set an add-on setting as boolean""" + try: + return ADDON.setSettingBool(key, value) + except (AttributeError, TypeError): # On Krypton or older, or when not a boolean + if value in ['false', 'true']: + return set_setting(key, value) + if value: + return set_setting(key, 'true') + return set_setting(key, 'false') + + +def set_setting_int(key, value): + """Set an add-on setting as integer""" + try: + return ADDON.setSettingInt(key, value) + except (AttributeError, TypeError): # On Krypton or older, or when not an integer + return set_setting(key, value) + + +def set_setting_float(key, value): + """Set an add-on setting""" + try: + return ADDON.setSettingNumber(key, value) + except (AttributeError, TypeError): # On Krypton or older, or when not a float + return set_setting(key, value) + + +def open_settings(): + """Open the add-in settings window, shows Credentials""" + ADDON.openSettings() + + +def get_global_setting(key): + """Get a Kodi setting""" + result = jsonrpc(method='Settings.GetSettingValue', params=dict(setting=key)) + return result.get('result', {}).get('value') + + +def get_cond_visibility(condition): + """Test a condition in XBMC""" + return xbmc.getCondVisibility(condition) + + +def has_addon(name): + """Checks if add-on is installed""" + return xbmc.getCondVisibility('System.HasAddon(%s)' % name) == 1 + + +def kodi_version(): + """Returns major Kodi version""" + return int(xbmc.getInfoLabel('System.BuildVersion').split('.')[0]) + + +def get_tokens_path(): + """Cache and return the userdata tokens path""" + if not hasattr(get_tokens_path, 'cached'): + get_tokens_path.cached = addon_profile() + 'tokens/' + return getattr(get_tokens_path, 'cached') + + +def get_cache_path(): + """Cache and return the userdata cache path""" + if not hasattr(get_cache_path, 'cached'): + get_cache_path.cached = addon_profile() + 'cache/' + return getattr(get_cache_path, 'cached') + + +def get_addon_info(key): + """Return addon information""" + return to_unicode(ADDON.getAddonInfo(key)) + + +def listdir(path): + """Return all files in a directory (using xbmcvfs)""" + from xbmcvfs import listdir as vfslistdir + return vfslistdir(path) + + +def mkdir(path): + """Create a directory (using xbmcvfs)""" + from xbmcvfs import mkdir as vfsmkdir + _LOGGER.debug("Create directory '{path}'.", path=path) + return vfsmkdir(path) + + +def mkdirs(path): + """Create directory including parents (using xbmcvfs)""" + from xbmcvfs import mkdirs as vfsmkdirs + _LOGGER.debug("Recursively create directory '{path}'.", path=path) + return vfsmkdirs(path) + + +def exists(path): + """Whether the path exists (using xbmcvfs)""" + from xbmcvfs import exists as vfsexists + return vfsexists(path) + + +@contextmanager +def open_file(path, flags='r'): + """Open a file (using xbmcvfs)""" + from xbmcvfs import File + fdesc = File(path, flags) + yield fdesc + fdesc.close() + + +def stat_file(path): + """Return information about a file (using xbmcvfs)""" + from xbmcvfs import Stat + return Stat(path) + + +def delete(path): + """Remove a file (using xbmcvfs)""" + from xbmcvfs import delete as vfsdelete + _LOGGER.debug("Delete file '{path}'.", path=path) + return vfsdelete(path) + + +def container_refresh(url=None): + """Refresh the current container or (re)load a container by URL""" + if url: + _LOGGER.debug('Execute: Container.Refresh({url})', url=url) + xbmc.executebuiltin('Container.Refresh({url})'.format(url=url)) + else: + _LOGGER.debug('Execute: Container.Refresh') + xbmc.executebuiltin('Container.Refresh') + + +def container_update(url): + """Update the current container while respecting the path history.""" + if url: + _LOGGER.debug('Execute: Container.Update({url})', url=url) + xbmc.executebuiltin('Container.Update({url})'.format(url=url)) + else: + # URL is a mandatory argument for Container.Update, use Container.Refresh instead + container_refresh() + + +def end_of_directory(): + """Close a virtual directory, required to avoid a waiting Kodi""" + from resources.lib.addon import routing + xbmcplugin.endOfDirectory(handle=routing.handle, succeeded=False, updateListing=False, cacheToDisc=False) + + +def jsonrpc(*args, **kwargs): + """Perform JSONRPC calls""" + from json import dumps, loads + + # We do not accept both args and kwargs + if args and kwargs: + _LOGGER.error('Wrong use of jsonrpc()') + return None + + # Process a list of actions + if args: + for (idx, cmd) in enumerate(args): + if cmd.get('id') is None: + cmd.update(id=idx) + if cmd.get('jsonrpc') is None: + cmd.update(jsonrpc='2.0') + return loads(xbmc.executeJSONRPC(dumps(args))) + + # Process a single action + if kwargs.get('id') is None: + kwargs.update(id=0) + if kwargs.get('jsonrpc') is None: + kwargs.update(jsonrpc='2.0') + return loads(xbmc.executeJSONRPC(dumps(kwargs))) + + +def get_cache(key, ttl=None): + """ Get an item from the cache """ + import time + path = get_cache_path() + file = '.'.join(key) + fullpath = path + file + + if not exists(fullpath): + return None + + if ttl and time.mktime(time.localtime()) - stat_file(fullpath).st_mtime() > ttl: + return None + + with open_file(fullpath, 'r') as fdesc: + try: + _LOGGER.info('Fetching {file} from cache', file=file) + import json + value = json.load(fdesc) + return value + except (ValueError, TypeError): + return None + + +def set_cache(key, data): + """ Store an item in the cache """ + path = get_cache_path() + file = '.'.join(key) + fullpath = path + file + + if not exists(path): + mkdirs(path) + + with open_file(fullpath, 'w') as fdesc: + _LOGGER.info('Storing to cache as {file}', file=file) + import json + json.dump(data, fdesc) diff --git a/resources/lib/modules/__init__.py b/resources/lib/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/lib/modules/catalog.py b/resources/lib/modules/catalog.py new file mode 100644 index 0000000..9647ab8 --- /dev/null +++ b/resources/lib/modules/catalog.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +""" Catalog module """ + +from __future__ import absolute_import, division, unicode_literals + +import logging + +from resources.lib import kodiutils +from resources.lib.kodiutils import TitleItem +from resources.lib.modules.menu import Menu +from resources.lib.viervijfzes import CHANNELS +from resources.lib.viervijfzes.auth import AuthApi +from resources.lib.viervijfzes.content import ContentApi, UnavailableException + +_LOGGER = logging.getLogger('catalog') + + +class Catalog: + """ Menu code related to the catalog """ + + def __init__(self): + """ Initialise object """ + self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) + self._api = ContentApi(self._auth.get_token()) + self._menu = Menu() + + def show_catalog(self): + """ Show all the programs of all channels """ + try: + items = [] + for channel in list(CHANNELS): + items.extend(self._api.get_programs(channel)) + except Exception as ex: + kodiutils.notification(message=str(ex)) + raise + + listing = [self._menu.generate_titleitem(item) for item in items] + + # Sort items by label, but don't put folders at the top. + # Used for A-Z listing or when movies and episodes are mixed. + kodiutils.show_listing(listing, 30003, content='tvshows', sort='label') + + def show_catalog_channel(self, channel): + """ Show the programs of a specific channel + :type channel: str + """ + try: + items = self._api.get_programs(channel) + except Exception as ex: + kodiutils.notification(message=str(ex)) + raise + + listing = [] + for item in items: + listing.append(self._menu.generate_titleitem(item)) + + # Sort items by label, but don't put folders at the top. + # Used for A-Z listing or when movies and episodes are mixed. + kodiutils.show_listing(listing, 30003, content='tvshows', sort='label') + + def show_program(self, channel, program_id): + """ Show a program from the catalog + :type channel: str + :type program_id: str + """ + try: + program = self._api.get_program(channel, program_id) + except UnavailableException: + kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the Vier/Vijf/Zes catalogue. + kodiutils.end_of_directory() + return + + if len(program.episodes) == 0: + kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the Vier/Vijf/Zes catalogue. + kodiutils.end_of_directory() + return + + # Go directly to the season when we have only one season + if len(program.seasons) == 1: + self.show_program_season(channel, program_id, program.seasons.values()[0].number) + return + + studio = CHANNELS.get(program.channel, {}).get('studio_icon') + + listing = [] + + # Add an '* All seasons' entry when configured in Kodi + if kodiutils.get_global_setting('videolibrary.showallitems') is True: + listing.append( + TitleItem(title='* %s' % kodiutils.localize(30204), # * All seasons + path=kodiutils.url_for('show_catalog_program_season', channel=channel, program=program_id, season=-1), + art_dict={ + 'thumb': program.cover, + 'fanart': program.background, + }, + info_dict={ + 'tvshowtitle': program.title, + 'title': kodiutils.localize(30204), # All seasons + 'plot': program.description, + 'set': program.title, + 'studio': studio, + }) + ) + + # Add the seasons + for s in list(program.seasons.values()): + listing.append( + TitleItem(title=s.title, # kodiutils.localize(30205, season=s.number), # Season {season} + path=kodiutils.url_for('show_catalog_program_season', channel=channel, program=program_id, season=s.number), + art_dict={ + 'thumb': s.cover, + 'fanart': program.background, + }, + info_dict={ + 'tvshowtitle': program.title, + 'title': kodiutils.localize(30205, season=s.number), # Season {season} + 'plot': s.description, + 'set': program.title, + 'studio': studio, + }) + ) + + # Sort by label. Some programs return seasons unordered. + kodiutils.show_listing(listing, 30003, content='tvshows', sort=['label']) + + def show_program_season(self, channel, program_id, season): + """ Show the episodes of a program from the catalog + :type channel: str + :type program_id: str + :type season: int + """ + try: + program = self._api.get_program(channel, program_id) + except UnavailableException: + kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the Vier/Vijf/Zes catalogue. + kodiutils.end_of_directory() + return + + if season == -1: + # Show all episodes + episodes = program.episodes + else: + # Show the episodes of the season that was selected + episodes = [e for e in program.episodes if e.season == season] + + listing = [self._menu.generate_titleitem(episode) for episode in episodes] + + # Sort by episode number by default. Takes seasons into account. + kodiutils.show_listing(listing, 30003, content='episodes', sort=['episode', 'duration']) diff --git a/resources/lib/modules/channels.py b/resources/lib/modules/channels.py new file mode 100644 index 0000000..f0fa2bf --- /dev/null +++ b/resources/lib/modules/channels.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +""" Channels module """ + +from __future__ import absolute_import, division, unicode_literals + +import logging + +from resources.lib import kodiutils +from resources.lib.kodiutils import TitleItem +from resources.lib.viervijfzes import CHANNELS, STREAM_DICT + +_LOGGER = logging.getLogger('channels') + + +class Channels: + """ Menu code related to channels """ + + def __init__(self): + """ Initialise object """ + + @staticmethod + def show_channels(): + """ Shows TV channels """ + listing = [] + for i, key in enumerate(CHANNELS): # pylint: disable=unused-variable + channel = CHANNELS[key] + + # Lookup the high resolution logo based on the channel name + icon = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel.get('logo')) + fanart = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel.get('background')) + + context_menu = [ + ( + kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel} + 'Container.Update(%s)' % + kodiutils.url_for('show_tvguide_channel', channel=channel.get('epg')) + ) + ] + + listing.append( + TitleItem(title=channel.get('name'), + path=kodiutils.url_for('show_channel_menu', channel=key), + art_dict={ + 'icon': icon, + 'thumb': icon, + 'fanart': fanart, + }, + info_dict={ + 'plot': None, + 'playcount': 0, + 'mediatype': 'video', + 'studio': channel.get('studio_icon'), + }, + stream_dict=STREAM_DICT, + context_menu=context_menu), + ) + + kodiutils.show_listing(listing, 30007) + + @staticmethod + def show_channel_menu(key): + """ Shows a TV channel + :type key: str + """ + channel = CHANNELS[key] + + # Lookup the high resolution logo based on the channel name + fanart = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel.get('background')) + + listing = [ + TitleItem(title=kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel} + path=kodiutils.url_for('show_tvguide_channel', channel=key), + art_dict={ + 'icon': 'DefaultAddonTvInfo.png', + 'fanart': fanart, + }, + info_dict={ + 'plot': kodiutils.localize(30054, channel=channel.get('name')), # Browse the TV Guide for {channel} + }), + TitleItem(title=kodiutils.localize(30055, channel=channel.get('name')), # Catalog for {channel} + path=kodiutils.url_for('show_catalog_channel', channel=key), + art_dict={ + 'icon': 'DefaultMovieTitle.png', + 'fanart': fanart, + }, + info_dict={ + 'plot': kodiutils.localize(30056, channel=channel.get('name')), # Browse the Catalog for {channel} + }) + ] + + # Add YouTube channels + if kodiutils.get_cond_visibility('System.HasAddon(plugin.video.youtube)') != 0: + for youtube in channel.get('youtube', []): + listing.append( + TitleItem(title=kodiutils.localize(30206, label=youtube.get('label')), # Watch {label} on YouTube + path=youtube.get('path'), + info_dict={ + 'plot': kodiutils.localize(30206, label=youtube.get('label')), # Watch {label} on YouTube + }) + ) + + kodiutils.show_listing(listing, 30007, sort=['unsorted']) diff --git a/resources/lib/modules/menu.py b/resources/lib/modules/menu.py new file mode 100644 index 0000000..1707faf --- /dev/null +++ b/resources/lib/modules/menu.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +""" Menu module """ + +from __future__ import absolute_import, division, unicode_literals + +from resources.lib import kodiutils +from resources.lib.kodiutils import TitleItem +from resources.lib.viervijfzes import CHANNELS +from resources.lib.viervijfzes.content import Program, Episode + + +class Menu: + """ Menu code """ + + def __init__(self): + """ Initialise object """ + + @staticmethod + def show_mainmenu(): + """ Show the main menu """ + listing = [ + TitleItem(title=kodiutils.localize(30001), # A-Z + path=kodiutils.url_for('show_catalog'), + art_dict=dict( + icon='DefaultMovieTitle.png', + fanart=kodiutils.get_addon_info('fanart'), + ), + info_dict=dict( + plot=kodiutils.localize(30002), + )), + TitleItem(title=kodiutils.localize(30007), # TV Channels + path=kodiutils.url_for('show_channels'), + art_dict=dict( + icon='DefaultAddonPVRClient.png', + fanart=kodiutils.get_addon_info('fanart'), + ), + info_dict=dict( + plot=kodiutils.localize(30008), + )), + TitleItem(title=kodiutils.localize(30009), # Search + path=kodiutils.url_for('show_search'), + art_dict=dict( + icon='DefaultAddonsSearch.png', + fanart=kodiutils.get_addon_info('fanart'), + ), + info_dict=dict( + plot=kodiutils.localize(30010), + )) + ] + + kodiutils.show_listing(listing, sort=['unsorted']) + + @staticmethod + def generate_titleitem(item): + """ Generate a TitleItem based on a Program or Episode. + :type item: Union[Program, Episode] + :rtype TitleItem + """ + art_dict = { + 'thumb': item.cover, + 'cover': item.cover, + } + info_dict = { + 'title': item.title, + 'plot': item.description, + 'studio': CHANNELS.get(item.channel, {}).get('studio_icon'), + 'aired': item.aired.strftime('%Y-%m-%d') if item.aired else None, + } + prop_dict = {} + + # + # Program + # + if isinstance(item, Program): + art_dict.update({ + 'fanart': item.background, + }) + info_dict.update({ + 'mediatype': None, + 'season': len(item.seasons) if item.seasons else None, + }) + + return TitleItem(title=item.title, + path=kodiutils.url_for('show_catalog_program', channel=item.channel, program=item.path), + art_dict=art_dict, + info_dict=info_dict) + + # + # Episode + # + if isinstance(item, Episode): + art_dict.update({ + 'fanart': item.cover, + }) + info_dict.update({ + 'mediatype': 'episode', + 'tvshowtitle': item.program_title, + 'duration': item.duration, + 'season': item.season, + 'episode': item.number, + 'aired': item.aired.strftime('%Y-%m-%d'), + }) + + stream_dict = { + 'codec': 'h264', + 'duration': item.duration, + 'height': 576, + 'width': 720, + } + + return TitleItem(title=info_dict['title'], + path=kodiutils.url_for('play', channel=item.channel, uuid=item.uuid), + art_dict=art_dict, + info_dict=info_dict, + stream_dict=stream_dict, + prop_dict=prop_dict, + is_playable=True) + + raise Exception('Unknown video_type') diff --git a/resources/lib/modules/player.py b/resources/lib/modules/player.py new file mode 100644 index 0000000..c7dd59e --- /dev/null +++ b/resources/lib/modules/player.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +""" Player module """ + +from __future__ import absolute_import, division, unicode_literals + +import logging + +from resources.lib import kodiutils +from resources.lib.viervijfzes.auth import AuthApi +from resources.lib.viervijfzes.content import ContentApi, UnavailableException, GeoblockedException + +_LOGGER = logging.getLogger('player') + + +class Player: + """ Code responsible for playing media """ + + def __init__(self): + """ Initialise object """ + self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) + self._api = ContentApi(self._auth.get_token()) + + def play_from_page(self, channel, path): + """ Play the requested item. + :type channel: string + :type path: string + """ + # Get episode information + episode = self._api.get_episode(channel, path) + + # Play this now we have the uuid + self.play(channel, episode.uuid) + + def play(self, channel, item): + """ Play the requested item. + :type channel: string + :type item: string + """ + try: + # Get stream information + resolved_stream = self._api.get_stream(channel, item) + + except GeoblockedException: + kodiutils.ok_dialog(heading=kodiutils.localize(30709), message=kodiutils.localize(30710)) # This video is geo-blocked... + return + + except UnavailableException: + kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30712)) # The video is unavailable... + return + + # Play this item + kodiutils.play(resolved_stream) diff --git a/resources/lib/modules/search.py b/resources/lib/modules/search.py new file mode 100644 index 0000000..b212d94 --- /dev/null +++ b/resources/lib/modules/search.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +""" Search module """ + +from __future__ import absolute_import, division, unicode_literals + +import logging + +from resources.lib import kodiutils +from resources.lib.modules.menu import Menu +from resources.lib.viervijfzes.search import SearchApi + +_LOGGER = logging.getLogger('search') + + +class Search: + """ Menu code related to search """ + + def __init__(self): + """ Initialise object """ + self._search = SearchApi() + self._menu = Menu() + + def show_search(self, query=None): + """ Shows the search dialog + :type query: str + """ + if not query: + # Ask for query + query = kodiutils.get_search_string(heading=kodiutils.localize(30009)) # Search Vier/Vijf/Zes + if not query: + kodiutils.end_of_directory() + return + + # Do search + try: + items = self._search.search(query) + except Exception as ex: # pylint: disable=broad-except + kodiutils.notification(message=str(ex)) + kodiutils.end_of_directory() + return + + # Display results + listing = [self._menu.generate_titleitem(item) for item in items] + + # Sort like we get our results back. + kodiutils.show_listing(listing, 30009, content='tvshows') diff --git a/resources/lib/modules/tvguide.py b/resources/lib/modules/tvguide.py new file mode 100644 index 0000000..50452af --- /dev/null +++ b/resources/lib/modules/tvguide.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +""" Menu code related to channels """ + +from __future__ import absolute_import, division, unicode_literals + +import logging +from datetime import datetime, timedelta + +from resources.lib import kodiutils +from resources.lib.kodiutils import TitleItem +from resources.lib.viervijfzes import STREAM_DICT +from resources.lib.viervijfzes.content import UnavailableException +from resources.lib.viervijfzes.epg import EpgApi + +try: # Python 3 + from urllib.parse import quote +except ImportError: # Python 2 + from urllib import quote + +_LOGGER = logging.getLogger('tvguide') + + +class TvGuide: + """ Menu code related to the TV Guide """ + + def __init__(self): + """ Initialise object """ + self._epg = EpgApi() + + @staticmethod + def get_dates(date_format): + """ Return a dict of dates. + :rtype: list[dict] + """ + dates = [] + today = datetime.today() + + # The API provides 7 days in the past and 13 days in the future + for i in range(-7, 13): + day = today + timedelta(days=i) + + if i == -1: + dates.append({ + 'title': '%s, %s' % (kodiutils.localize(30301), day.strftime(date_format)), # Yesterday + 'key': 'yesterday', + 'date': day.strftime('%d.%m.%Y'), + 'highlight': False, + }) + elif i == 0: + dates.append({ + 'title': '%s, %s' % (kodiutils.localize(30302), day.strftime(date_format)), # Today + 'key': 'today', + 'date': day.strftime('%d.%m.%Y'), + 'highlight': True, + }) + elif i == 1: + dates.append({ + 'title': '%s, %s' % (kodiutils.localize(30303), day.strftime(date_format)), # Tomorrow + 'key': 'tomorrow', + 'date': day.strftime('%d.%m.%Y'), + 'highlight': False, + }) + else: + dates.append({ + 'title': day.strftime(date_format), + 'key': day.strftime('%Y-%m-%d'), + 'date': day.strftime('%d.%m.%Y'), + 'highlight': False, + }) + + return dates + + def show_tvguide_channel(self, channel): + """ Shows the dates in the tv guide + :type channel: str + """ + listing = [] + for day in self.get_dates('%A %d %B %Y'): + if day.get('highlight'): + title = '[B]{title}[/B]'.format(title=day.get('title')) + else: + title = day.get('title') + + listing.append( + TitleItem(title=title, + path=kodiutils.url_for('show_tvguide_detail', channel=channel, date=day.get('key')), + art_dict={ + 'icon': 'DefaultYear.png', + 'thumb': 'DefaultYear.png', + }, + info_dict={ + 'plot': None, + 'date': day.get('date'), + }) + ) + + kodiutils.show_listing(listing, 30013, content='files', sort=['date']) + + def show_tvguide_detail(self, channel=None, date=None): + """ Shows the programs of a specific date in the tv guide + :type channel: str + :type date: str + """ + try: + programs = self._epg.get_epg(channel=channel, date=date) + except UnavailableException as ex: + kodiutils.notification(message=str(ex)) + kodiutils.end_of_directory() + return + + listing = [] + for program in programs: + if program.program_url: + context_menu = [( + kodiutils.localize(30102), # Go to Program + 'Container.Update(%s)' % + kodiutils.url_for('show_catalog_program', channel=channel, program=program.program_url) + )] + else: + context_menu = None + + title = '{time} - {title}'.format( + time=program.start.strftime('%H:%M'), + title=program.program_title + ) + + if program.airing: + title = '[B]{title}[/B]'.format(title=title) + + if program.video_url: + path = kodiutils.url_for('play_from_page', channel=channel, page=quote(program.video_url, safe='')) + else: + path = None + title = '[COLOR gray]' + title + '[/COLOR]' + + listing.append( + TitleItem(title=title, + path=path, + art_dict={ + 'icon': program.cover, + 'thumb': program.cover, + }, + info_dict={ + 'title': title, + 'plot': program.description, + 'duration': program.duration, + 'mediatype': 'video', + }, + stream_dict=STREAM_DICT + { + 'duration': program.duration, + }, + context_menu=context_menu, + is_playable=True) + ) + + kodiutils.show_listing(listing, 30013, content='episodes', sort=['unsorted']) + + def play_epg_datetime(self, channel, timestamp): + """ Play a program based on the channel and the timestamp when it was aired + :type channel: str + :type timestamp: str + """ + broadcast = self._epg.get_broadcast(channel, timestamp) + if not broadcast: + kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30713)) # The requested video was not found in the guide. + kodiutils.end_of_directory() + return + + kodiutils.container_update( + kodiutils.url_for('play', channel=channel, uuid=broadcast.video_url)) diff --git a/resources/lib/viervijfzes/__init__.py b/resources/lib/viervijfzes/__init__.py new file mode 100644 index 0000000..0260b05 --- /dev/null +++ b/resources/lib/viervijfzes/__init__.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +""" SBS API """ +from __future__ import absolute_import, division, unicode_literals + +from collections import OrderedDict + +CHANNELS = OrderedDict([ + ('vier', dict( + name='VIER', + url='https://www.vier.be', + logo='vier.png', + background='vier-background.jpg', + studio_icon='vier', + youtube=[ + dict( + label='VIER / VIJF', + logo='vier.png', + path='plugin://plugin.video.youtube/user/viertv/', + ), + ], + )), + ('vijf', dict( + name='VIJF', + url='https://www.vijf.be', + logo='vijf.png', + background='vijf-background.jpg', + studio_icon='vijf', + youtube=[ + dict( + label='VIER / VIJF', + logo='vijf.png', + path='plugin://plugin.video.youtube/user/viertv/', + ), + ], + )), + ('zes', dict( + name='ZES', + url='https://www.zestv.be', + logo='zes.png', + background='zes-background.jpg', + studio_icon='zes', + youtube=[], + )) +]) + +STREAM_DICT = { + 'codec': 'h264', + 'height': 960, + 'width': 544, +} diff --git a/resources/lib/viervijfzes/auth.py b/resources/lib/viervijfzes/auth.py new file mode 100644 index 0000000..134f30a --- /dev/null +++ b/resources/lib/viervijfzes/auth.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +""" AUTH API """ + +from __future__ import absolute_import, division, unicode_literals + +import json +import logging +import os +import time + +from resources.lib.viervijfzes.auth_awsidp import AwsIdp + +_LOGGER = logging.getLogger('auth-api') + + +class AuthApi: + """ Vier/Vijf/Zes Authentication API """ + COGNITO_REGION = 'eu-west-1' + COGNITO_POOL_ID = 'eu-west-1_dViSsKM5Y' + COGNITO_CLIENT_ID = '6s1h851s8uplco5h6mqh1jac8m' + + TOKEN_FILE = 'auth-tokens.json' + + def __init__(self, username, password, cache_dir=None): + """ Initialise object """ + self._username = username + self._password = password + self._cache = cache_dir + self._id_token = None + self._expiry = 0 + self._refresh_token = None + + if self._cache: + # Load tokens from cache + try: + with open(self._cache + self.TOKEN_FILE, 'rb') as f: + data_json = json.loads(f.read()) + self._id_token = data_json.get('id_token') + self._refresh_token = data_json.get('refresh_token') + self._expiry = int(data_json.get('expiry', 0)) + except (IOError, TypeError, ValueError): + _LOGGER.info('We could not use the cache since it is invalid or non-existant.') + + def get_token(self): + """ Get a valid token """ + now = int(time.time()) + + if self._id_token and self._expiry > now: + # We have a valid id token in memory, use it + _LOGGER.debug('Got an id token from memory: %s', self._id_token) + return self._id_token + + if self._refresh_token: + # We have a valid refresh token, use that to refresh our id token + # The refresh token is valid for 30 days. If this refresh fails, we just continue by logging in again. + self._id_token = self._refresh(self._refresh_token) + if self._id_token: + self._expiry = now + 3600 + _LOGGER.debug('Got an id token by refreshing: %s', self._id_token) + + if not self._id_token: + # We have no tokens, or they are all invalid, do a login + id_token, refresh_token = self._authenticate(self._username, self._password) + self._id_token = id_token + self._refresh_token = refresh_token + self._expiry = now + 3600 + _LOGGER.debug('Got an id token by logging in: %s', self._id_token) + + if self._cache: + if not os.path.isdir(self._cache): + os.mkdir(self._cache) + + # Store new tokens in cache + with open(self._cache + self.TOKEN_FILE, 'wb') as f: + f.write(json.dumps(dict( + id_token=self._id_token, + refresh_token=self._refresh_token, + expiry=self._expiry, + ))) + + return self._id_token + + def clear_cache(self): + """ Remove the cached tokens. """ + if not self._cache: + return + + # Remove cache + os.remove(self._cache + self.TOKEN_FILE) + + # Clear tokens in memory + self._id_token = None + self._refresh_token = None + self._expiry = 0 + + @staticmethod + def _authenticate(username, password): + """ Authenticate with Amazon Cognito and fetch a refresh token and id token. """ + client = AwsIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID) + return client.authenticate(username, password) + + @staticmethod + def _refresh(refresh_token): + """ Use the refresh token to fetch a new id token. """ + client = AwsIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID) + return client.renew_token(refresh_token) diff --git a/resources/lib/viervijfzes/auth_awsidp.py b/resources/lib/viervijfzes/auth_awsidp.py new file mode 100644 index 0000000..73b1fe0 --- /dev/null +++ b/resources/lib/viervijfzes/auth_awsidp.py @@ -0,0 +1,352 @@ +# -*- coding: utf-8 -*- +""" Amazon Cognito implementation without external dependencies """ +# Based on https://github.com/retrospect-addon/plugin.video.retrospect/blob/master/channels/channel.be/vier/awsidp.py + +from __future__ import absolute_import, division, unicode_literals + +import base64 +import binascii +import datetime +import hashlib +import hmac +import json +import logging +import os +import sys + +import requests +import six + +_LOGGER = logging.getLogger('auth-awsidp') + + +class AwsIdp: + """ AWS Identity Provider """ + + def __init__(self, pool_id, client_id): + """ + :param str pool_id: The AWS user pool to connect to (format: _). + E.g.: eu-west-1_aLkOfYN3T + :param str client_id: The client application ID (the ID of the application connecting) + """ + + self.pool_id = pool_id + if "_" not in self.pool_id: + raise ValueError("Invalid pool_id format. Shoud be _.") + + self.client_id = client_id + self.region = self.pool_id.split("_")[0] + self.url = "https://cognito-idp.%s.amazonaws.com/" % (self.region,) + self._session = requests.session() + + # Initialize the values + # https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L22 + self.n_hex = 'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1' + \ + '29024E088A67CC74020BBEA63B139B22514A08798E3404DD' + \ + 'EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245' + \ + 'E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED' + \ + 'EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D' + \ + 'C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F' + \ + '83655D23DCA3AD961C62F356208552BB9ED529077096966D' + \ + '670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B' + \ + 'E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9' + \ + 'DE2BCBF6955817183995497CEA956AE515D2261898FA0510' + \ + '15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64' + \ + 'ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7' + \ + 'ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B' + \ + 'F12FFA06D98A0864D87602733EC86A64521F2B18177B200C' + \ + 'BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31' + \ + '43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF' + + # https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L49 + self.g_hex = '2' + self.info_bits = bytearray('Caldera Derived Key', 'utf-8') + + self.big_n = self.__hex_to_long(self.n_hex) + self.g = self.__hex_to_long(self.g_hex) + self.k = self.__hex_to_long(self.__hex_hash('00' + self.n_hex + '0' + self.g_hex)) + self.small_a_value = self.__generate_random_small_a() + self.large_a_value = self.__calculate_a() + _LOGGER.debug("Created %s", self) + + def authenticate(self, username, password): + """ Authenticate with a username and password. """ + # Step 1: First initiate an authentication request + auth_request = self.__get_authentication_request(username) + auth_data = json.dumps(auth_request) + auth_headers = { + "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth", + "Accept-Encoding": "identity", + "Content-Type": "application/x-amz-json-1.1" + } + auth_response = self._session.post(self.url, auth_data, headers=auth_headers) + auth_response_json = json.loads(auth_response.content) + challenge_parameters = auth_response_json.get("ChallengeParameters") + _LOGGER.debug(challenge_parameters) + + challenge_name = auth_response_json.get("ChallengeName") + if not challenge_name == "PASSWORD_VERIFIER": + message = auth_response_json.get("message") + _LOGGER.error("Cannot start authentication challenge: %s", message or None) + return None + + # Step 2: Respond to the Challenge with a valid ChallengeResponse + challenge_request = self.__get_challenge_response_request(challenge_parameters, password) + challenge_data = json.dumps(challenge_request) + challenge_headers = { + "X-Amz-Target": "AWSCognitoIdentityProviderService.RespondToAuthChallenge", + "Content-Type": "application/x-amz-json-1.1" + } + auth_response = self._session.post(self.url, challenge_data, headers=challenge_headers) + auth_response_json = json.loads(auth_response.content) + _LOGGER.debug("Got response: %s", auth_response_json) + + if "message" in auth_response_json: + _LOGGER.error("Error logging in: %s", auth_response_json.get("message")) + return None, None + + id_token = auth_response_json.get("AuthenticationResult", {}).get("IdToken") + refresh_token = auth_response_json.get("AuthenticationResult", {}).get("RefreshToken") + return id_token, refresh_token + + def renew_token(self, refresh_token): + """ + Sets a new access token on the User using the refresh token. The basic expire time of the + refresh token is 30 days: + + http://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html + + :param str refresh_token: Token to use for refreshing the authorization token. + """ + refresh_request = { + "AuthParameters": { + "REFRESH_TOKEN": refresh_token + }, + "ClientId": self.client_id, + "AuthFlow": "REFRESH_TOKEN" + } + refresh_headers = { + "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth", + "Content-Type": "application/x-amz-json-1.1" + } + refresh_request_data = json.dumps(refresh_request) + refresh_response = self._session.post(self.url, refresh_request_data, headers=refresh_headers) + refresh_json = json.loads(refresh_response.content) + + if "message" in refresh_json: + _LOGGER.error("Error refreshing: %s", refresh_json.get("message")) + return None + + id_token = refresh_json.get("AuthenticationResult", {}).get("IdToken") + return id_token + + def __get_authentication_request(self, username): + """ + + :param str username: The username to use + + :return: A full Authorization request. + :rtype: dict + """ + auth_request = { + "AuthParameters": { + "USERNAME": username, + "SRP_A": self.__long_to_hex(self.large_a_value) + }, + "AuthFlow": "USER_SRP_AUTH", + "ClientId": self.client_id + } + return auth_request + + def __get_challenge_response_request(self, challenge_parameters, password): + """ Create a Challenge Response Request object. + + :param dict[str,str|imt] challenge_parameters: The parameters for the challenge. + :param str password: The password. + + :return: A valid and full request data object to use as a response for a challenge. + :rtype: dict + """ + user_id = challenge_parameters["USERNAME"] + user_id_for_srp = challenge_parameters["USER_ID_FOR_SRP"] + srp_b = challenge_parameters["SRP_B"] + salt = challenge_parameters["SALT"] + secret_block = challenge_parameters["SECRET_BLOCK"] + + timestamp = self.__get_current_timestamp() + + # Get a HKDF key for the password, SrpB and the Salt + hkdf = self.__get_hkdf_key_for_password( + user_id_for_srp, + password, + self.__hex_to_long(srp_b), + salt + ) + secret_block_bytes = base64.standard_b64decode(secret_block) + + # the message is a combo of the pool_id, provided SRP userId, the Secret and Timestamp + msg = bytearray(self.pool_id.split('_')[1], 'utf-8') + \ + bytearray(user_id_for_srp, 'utf-8') + \ + bytearray(secret_block_bytes) + \ + bytearray(timestamp, 'utf-8') + hmac_obj = hmac.new(hkdf, msg, digestmod=hashlib.sha256) + signature_string = base64.standard_b64encode(hmac_obj.digest()).decode('utf-8') + challenge_request = { + "ChallengeResponses": { + "USERNAME": user_id, + "TIMESTAMP": timestamp, + "PASSWORD_CLAIM_SECRET_BLOCK": secret_block, + "PASSWORD_CLAIM_SIGNATURE": signature_string + }, + "ChallengeName": "PASSWORD_VERIFIER", + "ClientId": self.client_id + } + return challenge_request + + def __get_hkdf_key_for_password(self, username, password, server_b_value, salt): + """ Calculates the final hkdf based on computed S value, and computed U value and the key. + + :param str username: Username. + :param str password: Password. + :param int server_b_value: Server B value. + :param int salt: Generated salt. + + :return Computed HKDF value. + :rtype: object + """ + + u_value = self.__calculate_u(self.large_a_value, server_b_value) + if u_value == 0: + raise ValueError('U cannot be zero.') + username_password = '%s%s:%s' % (self.pool_id.split('_')[1], username, password) + username_password_hash = self.__hash_sha256(username_password.encode('utf-8')) + + x_value = self.__hex_to_long(self.__hex_hash(self.__pad_hex(salt) + username_password_hash)) + g_mod_pow_xn = pow(self.g, x_value, self.big_n) + int_value2 = server_b_value - self.k * g_mod_pow_xn + s_value = pow(int_value2, self.small_a_value + u_value * x_value, self.big_n) + hkdf = self.__compute_hkdf( + bytearray.fromhex(self.__pad_hex(s_value)), + bytearray.fromhex(self.__pad_hex(self.__long_to_hex(u_value))) + ) + return hkdf + + def __compute_hkdf(self, ikm, salt): + """ Standard hkdf algorithm + + :param {Buffer} ikm Input key material. + :param {Buffer} salt Salt value. + :return {Buffer} Strong key material. + """ + + prk = hmac.new(salt, ikm, hashlib.sha256).digest() + info_bits_update = self.info_bits + bytearray(chr(1), 'utf-8') + hmac_hash = hmac.new(prk, info_bits_update, hashlib.sha256).digest() + return hmac_hash[:16] + + def __calculate_u(self, big_a, big_b): + """ Calculate the client's value U which is the hash of A and B + + :param int big_a: Large A value. + :param int big_b: Server B value. + + :return Computed U value. + :rtype: int + """ + + u_hex_hash = self.__hex_hash(self.__pad_hex(big_a) + self.__pad_hex(big_b)) + return self.__hex_to_long(u_hex_hash) + + def __generate_random_small_a(self): + """ Helper function to generate a random big integer + + :return a random value. + :rtype: int + """ + random_long_int = self.__get_random(128) + return random_long_int % self.big_n + + def __calculate_a(self): + """ Calculate the client's public value A = g^a%N with the generated random number a + + :return Computed large A. + :rtype: int + """ + + big_a = pow(self.g, self.small_a_value, self.big_n) + # safety check + if (big_a % self.big_n) == 0: + raise ValueError('Safety check for A failed') + return big_a + + @staticmethod + def __long_to_hex(long_num): + return '%x' % long_num + + @staticmethod + def __hex_to_long(hex_string): + return int(hex_string, 16) + + @staticmethod + def __hex_hash(hex_string): + return AwsIdp.__hash_sha256(bytearray.fromhex(hex_string)) + + @staticmethod + def __hash_sha256(buf): + """AuthenticationHelper.hash""" + a = hashlib.sha256(buf).hexdigest() + return (64 - len(a)) * '0' + a + + @staticmethod + def __pad_hex(long_int): + """ Converts a Long integer (or hex string) to hex format padded with zeroes for hashing + + :param int|str long_int: Number or string to pad. + + :return Padded hex string. + :rtype: str + """ + + # noinspection PyTypeChecker + if not isinstance(long_int, six.string_types): + hash_str = AwsIdp.__long_to_hex(long_int) + else: + hash_str = long_int + if len(hash_str) % 2 == 1: + hash_str = '0%s' % hash_str + elif hash_str[0] in '89ABCDEFabcdef': + hash_str = '00%s' % hash_str + return hash_str + + @staticmethod + def __get_random(nbytes): + random_hex = binascii.hexlify(os.urandom(nbytes)) + return AwsIdp.__hex_to_long(random_hex) + + @staticmethod + def __get_current_timestamp(): + """ Creates a timestamp with the correct English format. + + :return: timestamp in format 'Sun Jan 27 19:00:04 UTC 2019' + :rtype: str + """ + + # We need US only data, so we cannot just do a strftime: + # Sun Jan 27 19:00:04 UTC 2019 + months = [None, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + + time_now = datetime.datetime.utcnow() + if sys.platform.startswith('win'): + format_string = "{} {} %#d %H:%M:%S UTC %Y".format(days[time_now.weekday()], months[time_now.month]) + else: + format_string = "{} {} %-d %H:%M:%S UTC %Y".format(days[time_now.weekday()], months[time_now.month]) + + time_string = datetime.datetime.utcnow().strftime(format_string) + _LOGGER.debug("AWS Auth Timestamp: %s", time_string) + return time_string + + def __str__(self): + return "AWS IDP Client for:\nRegion: %s\nPoolId: %s\nAppId: %s" % ( + self.region, self.pool_id.split("_")[1], self.client_id + ) diff --git a/resources/lib/viervijfzes/content.py b/resources/lib/viervijfzes/content.py new file mode 100644 index 0000000..1eae505 --- /dev/null +++ b/resources/lib/viervijfzes/content.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- +""" AUTH API """ + +from __future__ import absolute_import, division, unicode_literals + +import json +import logging +import re +from datetime import datetime + +import requests +from six.moves.html_parser import HTMLParser + +from resources.lib.viervijfzes import CHANNELS + +_LOGGER = logging.getLogger('content-api') + + +class UnavailableException(Exception): + """ Is thrown when an item is unavailable. """ + + +class NoContentException(Exception): + """ Is thrown when no items are unavailable. """ + + +class GeoblockedException(Exception): + """ Is thrown when a geoblocked item is played. """ + + +class Program: + """ Defines a Program. """ + + def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, aired=None, cover=None, background=None, seasons=None, episodes=None): + """ + :type uuid: str + :type path: str + :type channel: str + :type title: str + :type description: str + :type aired: datetime + :type cover: str + :type background: str + :type seasons: list[Season] + :type episodes: list[Episode] + """ + self.uuid = uuid + self.path = path + self.channel = channel + self.title = title + self.description = description + self.aired = aired + self.cover = cover + self.background = background + self.seasons = seasons + self.episodes = episodes + + def __repr__(self): + return "%r" % self.__dict__ + + +class Season: + """ Defines a Season. """ + + def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, cover=None, number=None): + """ + :type uuid: str + :type path: str + :type channel: str + :type title: str + :type description: str + :type cover: str + :type number: int + + """ + self.uuid = uuid + self.path = path + self.channel = channel + self.title = title + self.description = description + self.cover = cover + self.number = number + + def __repr__(self): + return "%r" % self.__dict__ + + +class Episode: + """ Defines an Episode. """ + + def __init__(self, uuid=None, nodeid=None, path=None, channel=None, program_title=None, title=None, description=None, cover=None, duration=None, + season=None, number=None, + rating=None, aired=None, expiry=None): + """ + :type uuid: str + :type nodeid: str + :type path: str + :type channel: str + :type program_title: str + :type title: str + :type description: str + :type cover: str + :type duration: int + :type season: int + :type number: int + :type rating: str + :type aired: datetime + :type expiry: datetime + """ + self.uuid = uuid + self.nodeid = nodeid + self.path = path + self.channel = channel + self.program_title = program_title + self.title = title + self.description = description + self.cover = cover + self.duration = duration + self.season = season + self.number = number + self.rating = rating + self.aired = aired + self.expiry = expiry + + def __repr__(self): + return "%r" % self.__dict__ + + +class ContentApi: + """ Vier/Vijf/Zes Content API""" + API_ENDPOINT = 'https://api.viervijfzes.be' + + def __init__(self, token): + """ Initialise object """ + self._token = token + + self._session = requests.session() + self._session.headers['authorization'] = token + + def get_notifications(self): + """ Get a list of notifications for your account. + :rtype list[dict] + """ + response = self._get_url(self.API_ENDPOINT + '/notifications') + data = json.loads(response) + return data + + def get_stream(self, _channel, uuid): + """ Get the stream URL to use for this video. + :type _channel: str + :type uuid: str + :rtype str + """ + response = self._get_url(self.API_ENDPOINT + '/content/%s' % uuid) + data = json.loads(response) + return data['video']['S'] + + def get_programs(self, channel): + """ Get a list of all programs of the specified channel. + :type channel: str + :rtype list[Program] + NOTE: This function doesn't use an API. + """ + if channel not in CHANNELS: + raise Exception('Unknown channel %s' % channel) + + # Load webpage + data = self._get_url(CHANNELS[channel]['url']) + + # Parse programs + h = HTMLParser() + regex_programs = re.compile(r'\s+' + r'\s+(?P[^<]+)</span>.*?' + r'</a>', re.DOTALL) + + programs = [ + Program(channel=channel, + path=program.group('path').lstrip('/'), + title=h.unescape(program.group('title').strip())) + for program in regex_programs.finditer(data) + ] + + return programs + + def get_program(self, channel, path): + """ Get a Program object from the specified page. + :type channel: str + :type path: str + :rtype Program + NOTE: This function doesn't use an API. + """ + if channel not in CHANNELS: + raise Exception('Unknown channel %s' % channel) + + # Load webpage + page = self._get_url(CHANNELS[channel]['url'] + '/' + path) + + # Extract JSON + regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL) + json_data = HTMLParser().unescape(regex_program.search(page).group(1)) + data = json.loads(json_data)['data'] + program = self._parse_program_data(data) + + return program + + def get_episode(self, channel, path): + """ Get a Episode object from the specified page. + :type channel: str + :type path: str + :rtype Episode + NOTE: This function doesn't use an API. + """ + if channel not in CHANNELS: + raise Exception('Unknown channel %s' % channel) + + # Load webpage + page = self._get_url(CHANNELS[channel]['url'] + '/' + path) + + # Extract program JSON + h = HTMLParser() + regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL) + json_data = h.unescape(regex_program.search(page).group(1)) + data = json.loads(json_data)['data'] + program = self._parse_program_data(data) + + # Extract episode JSON + regex_episode = re.compile(r'<script type="application/json" data-drupal-selector="drupal-settings-json">(.*?)</script>', re.DOTALL) + json_data = h.unescape(regex_episode.search(page).group(1)) + data = json.loads(json_data) + + # Lookup the episode in the program JSON based on the nodeId + # The episode we just found doesn't contain all information + for episode in program.episodes: + if episode.nodeid == data['pageInfo']['nodeId']: + return episode + + return None + + @staticmethod + def _parse_program_data(data): + """ Parse the Program JSON. + :type data: dict + :rtype Program + """ + # Create Program info + program = Program( + uuid=data['id'], + path=data['link'].lstrip('/'), + channel=data['pageInfo']['site'], + title=data['title'], + description=data['description'], + aired=datetime.fromtimestamp(data.get('pageInfo', {}).get('publishDate')), + cover=data['images']['poster'], + background=data['images']['hero'], + ) + + # Create Season info + program.seasons = { + playlist['episodes'][0]['seasonNumber']: Season( + uuid=playlist['id'], + path=playlist['link'].lstrip('/'), + channel=playlist['pageInfo']['site'], + title=playlist['title'], + description=playlist['pageInfo']['description'], + number=playlist['episodes'][0]['seasonNumber'], # You did not see this + ) + for playlist in data['playlists'] + } + + # Create Episodes info + program.episodes = [ + ContentApi._parse_episode_data(episode) + for playlist in data['playlists'] + for episode in playlist['episodes'] + ] + + return program + + @staticmethod + def _parse_episode_data(data): + """ Parse the Episode JSON. + :type data: dict + :rtype Episode + """ + + if data.get('episodeNumber'): + episode_number = data.get('episodeNumber') + else: + # The episodeNumber can be absent + match = re.compile(r'\d+$').search(data.get('title')) + if match: + episode_number = match.group(0) + else: + episode_number = None + + if data.get('episodeTitle'): + episode_title = data.get('episodeTitle') + else: + episode_title = data.get('title') + + episode = Episode( + uuid=data.get('videoUuid'), + nodeid=data.get('pageInfo', {}).get('nodeId'), + path=data.get('link').lstrip('/'), + channel=data.get('pageInfo', {}).get('site'), + program_title=data.get('program', {}).get('title'), + title=episode_title, + description=data.get('pageInfo', {}).get('description'), + cover=data.get('image'), + duration=data.get('duration'), + season=data.get('seasonNumber'), + number=episode_number, + aired=datetime.fromtimestamp(data.get('createdDate')), + expiry=datetime.fromtimestamp(data.get('unpublishDate')) if data.get('unpublishDate') else None, + rating=data.get('parentalRating') + ) + return episode + + def _get_url(self, url, params=None): + """ Makes a GET request for the specified URL. + :type url: str + :rtype str + """ + response = self._session.get(url, params=params) + + if response.status_code != 200: + raise Exception('Could not fetch data') + + return response.text diff --git a/resources/lib/viervijfzes/epg.py b/resources/lib/viervijfzes/epg.py new file mode 100644 index 0000000..6cf5b79 --- /dev/null +++ b/resources/lib/viervijfzes/epg.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +""" EPG API """ + +from __future__ import absolute_import, division, unicode_literals + +import json +import logging +from datetime import datetime, timedelta + +import dateutil +import requests + +_LOGGER = logging.getLogger('epg-api') + + +class EpgProgram: + """ Defines a Program in the EPG. """ + + def __init__(self, channel, program_title, episode_title, episode_title_original, nr, season, genre, start, won_id, won_program_id, program_description, + description, duration, program_url, video_url, cover, airing): + self.channel = channel + self.program_title = program_title + self.episode_title = episode_title + self.episode_title_original = episode_title_original + self.nr = nr + self.season = season + self.genre = genre + self.start = start + self.won_id = won_id + self.won_program_id = won_program_id + self.program_description = program_description + self.description = description + self.duration = duration + self.program_url = program_url + self.video_url = video_url + self.cover = cover + self.airing = airing + + def __repr__(self): + return "%r" % self.__dict__ + + +class EpgApi: + """ Vier/Vijf/Zes EPG API """ + + EPG_ENDPOINTS = { + 'vier': 'https://www.vier.be/api/epg/{date}', + 'vijf': 'https://www.vijf.be/api/epg/{date}', + 'zes': 'https://www.zestv.be/api/epg/{date}', + } + + def __init__(self): + """ Initialise object """ + self._session = requests.session() + + def get_epg(self, channel, date): + """ Returns the EPG for the specified channel and date. + :type channel: str + :type date: str + :rtype list[EpgProgram] + """ + if channel not in self.EPG_ENDPOINTS: + raise Exception('Unknown channel %s' % channel) + + if date is None: + # Fetch today when no date is specified + date = datetime.today().strftime('%Y-%m-%d') + elif date == 'yesterday': + date = (datetime.today() + timedelta(days=-1)).strftime('%Y-%m-%d') + elif date == 'today': + date = datetime.today().strftime('%Y-%m-%d') + elif date == 'tomorrow': + date = (datetime.today() + timedelta(days=1)).strftime('%Y-%m-%d') + + # Request the epg data + response = self._get_url(self.EPG_ENDPOINTS.get(channel).format(date=date)) + data = json.loads(response) + + # Parse the results + return [self._parse_program(channel, x) for x in data] + + @staticmethod + def _parse_program(channel, data): + """ Parse the EPG JSON data to a EpgProgram object. + :type channel: str + :type data: dict + :rtype EpgProgram + """ + duration = int(data.get('duration')) if data.get('duration') else None + + # Check if this broadcast is currently airing + timestamp = datetime.now() + start = datetime.fromtimestamp(data.get('timestamp')) + if duration: + airing = bool(start <= timestamp < (start + timedelta(seconds=duration))) + else: + airing = False + + return EpgProgram( + channel=channel, + program_title=data.get('program_title'), + episode_title=data.get('episode_title'), + episode_title_original=data.get('original_title'), + nr=int(data.get('episode_nr')) if data.get('episode_nr') else None, + season=int(data.get('season')) if data.get('season') else None, + genre=data.get('genre'), + start=start, + won_id=int(data.get('won_id')) if data.get('won_id') else None, + won_program_id=int(data.get('won_program_id')) if data.get('won_program_id') else None, + program_description=data.get('program_concept'), + description=data.get('content_episode'), + duration=duration, + program_url=(data.get('program_node', {}).get('url') or '').lstrip('/'), + video_url=(data.get('video_node', {}).get('url') or '').lstrip('/'), + cover=data.get('video_node', {}).get('image'), + airing=airing, + ) + + def get_broadcast(self, channel, timestamp): + """ Load EPG information for the specified channel and date. + :type channel: str + :type timestamp: str + :rtype: EpgProgram + """ + # Parse to a real datetime + timestamp = dateutil.parser.parse(timestamp) + + # Load guide info for this date + programs = self.get_epg(channel=channel, date=timestamp.strftime('%Y-%m-%d')) + + # Find a matching broadcast + for broadcast in programs: + if timestamp <= broadcast.start < (broadcast.start + timedelta(seconds=broadcast.duration)): + return broadcast + + return None + + def _get_url(self, url): + """ Makes a GET request for the specified URL. + :type url: str + :rtype str + """ + response = self._session.get(url) + + # TODO check error code + + return response.text diff --git a/resources/lib/viervijfzes/search.py b/resources/lib/viervijfzes/search.py new file mode 100644 index 0000000..e8066fa --- /dev/null +++ b/resources/lib/viervijfzes/search.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +""" AUTH API """ + +from __future__ import absolute_import, division, unicode_literals + +import json +import logging + +import requests + +from resources.lib.viervijfzes.content import Program + +_LOGGER = logging.getLogger('search-api') + + +class SearchApi: + """ Vier/Vijf/Zes Search API """ + API_ENDPOINT = 'https://api.viervijfzes.be/search' + + def __init__(self): + """ Initialise object """ + self._session = requests.session() + + def search(self, query): + """ Get the stream URL to use for this video. + :type query: str + :rtype list[Program] + """ + response = self._session.post( + self.API_ENDPOINT, + json={ + "query": query, + "sites": ["vier", "vijf", "zes"], + "page": 0, + "mode": "byDate" + } + ) + data = json.loads(response.content) + + if data['timed_out']: + raise TimeoutError() + + results = [] + for hit in data['hits']['hits']: + if hit['_source']['bundle'] == 'program': + results.append(Program( + channel=hit['_source']['site'], + path=hit['_source']['url'].strip('/'), + title=hit['_source']['title'], + description=hit['_source']['intro'], + cover=hit['_source']['img'], + )) + + return results diff --git a/resources/logos/vier-background.jpg b/resources/logos/vier-background.jpg new file mode 100644 index 0000000..c27edc4 Binary files /dev/null and b/resources/logos/vier-background.jpg differ diff --git a/resources/logos/vier.png b/resources/logos/vier.png new file mode 100644 index 0000000..d0f79a3 Binary files /dev/null and b/resources/logos/vier.png differ diff --git a/resources/logos/vijf-background.jpg b/resources/logos/vijf-background.jpg new file mode 100644 index 0000000..a89b5ea Binary files /dev/null and b/resources/logos/vijf-background.jpg differ diff --git a/resources/logos/vijf.png b/resources/logos/vijf.png new file mode 100644 index 0000000..115451a Binary files /dev/null and b/resources/logos/vijf.png differ diff --git a/resources/logos/zes-background.jpg b/resources/logos/zes-background.jpg new file mode 100644 index 0000000..3a60b9a Binary files /dev/null and b/resources/logos/zes-background.jpg differ diff --git a/resources/logos/zes.png b/resources/logos/zes.png new file mode 100644 index 0000000..abc27d1 Binary files /dev/null and b/resources/logos/zes.png differ diff --git a/resources/settings.xml b/resources/settings.xml new file mode 100644 index 0000000..167f539 --- /dev/null +++ b/resources/settings.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8" standalone="yes"?> +<settings> + <category label="30800"> <!-- Credentials --> + <setting label="30801" type="lsep"/> <!-- Vier/Vijf/Zes login --> + <setting label="30803" type="text" id="username"/> + <setting label="30805" type="text" id="password" option="hidden"/> + </category> +</settings> diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..0fb3477 --- /dev/null +++ b/test/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +""" Tests """ + +import logging + +logging.basicConfig(level=logging.DEBUG) diff --git a/test/test_api.py b/test/test_api.py new file mode 100644 index 0000000..2a83390 --- /dev/null +++ b/test/test_api.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +""" Tests for Content API """ + +# pylint: disable=missing-docstring,no-self-use + +from __future__ import absolute_import, division, print_function, unicode_literals + +import logging +import unittest + +from resources.lib import kodiutils +from resources.lib.viervijfzes.auth import AuthApi +from resources.lib.viervijfzes.content import ContentApi, Program, Episode + +_LOGGER = logging.getLogger('test-api') + + +class TestApi(unittest.TestCase): + def __init__(self, *args, **kwargs): + super(TestApi, self).__init__(*args, **kwargs) + self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) + + def test_notifications(self): + api = ContentApi(self._auth.get_token()) + notifications = api.get_notifications() + self.assertIsInstance(notifications, list) + + def test_programs(self): + api = ContentApi(self._auth.get_token()) + + for channel in ['vier', 'vijf', 'zes']: + channels = api.get_programs(channel) + self.assertIsInstance(channels, list) + + def test_episodes(self): + api = ContentApi(self._auth.get_token()) + + for channel, program in [('vier', 'auwch'), ('vijf', 'zo-man-zo-vrouw')]: + program = api.get_program(channel, program) + self.assertIsInstance(program, Program) + self.assertIsInstance(program.seasons, dict) + # self.assertIsInstance(program.seasons[0], Season) + self.assertIsInstance(program.episodes, list) + self.assertIsInstance(program.episodes[0], Episode) + _LOGGER.info('Got program: %s', program) + + def test_get_stream(self): + api = ContentApi(self._auth.get_token()) + program = api.get_program('vier', 'auwch') + episode = program.episodes[0] + video = api.get_stream(episode.channel, episode.uuid) + self.assertTrue(video) + + _LOGGER.info('Got video URL: %s', video) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_auth.py b/test/test_auth.py new file mode 100644 index 0000000..f84d973 --- /dev/null +++ b/test/test_auth.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +""" Tests for AUTH API """ + +# pylint: disable=missing-docstring,no-self-use + +from __future__ import absolute_import, division, print_function, unicode_literals + +import logging +import unittest + +from resources.lib import kodiutils +from resources.lib.viervijfzes.auth import AuthApi + +_LOGGER = logging.getLogger('test-auth') + + +class TestAuth(unittest.TestCase): + def __init__(self, *args, **kwargs): + super(TestAuth, self).__init__(*args, **kwargs) + self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) + + def test_login(self): + # Clear any cache we have + self._auth.clear_cache() + + # We should get a token by logging in + token = self._auth.get_token() + self.assertTrue(token) + + # Test it a second time, it should go from memory now + token = self._auth.get_token() + self.assertTrue(token) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_epg.py b/test/test_epg.py new file mode 100644 index 0000000..1a17f3e --- /dev/null +++ b/test/test_epg.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +""" Tests for EPG API """ + +# pylint: disable=missing-docstring,no-self-use + +from __future__ import absolute_import, division, print_function, unicode_literals + +import logging +import unittest +from datetime import date + +from resources.lib import kodiutils +from resources.lib.viervijfzes.auth import AuthApi +from resources.lib.viervijfzes.content import ContentApi, Episode +from resources.lib.viervijfzes.epg import EpgApi, EpgProgram + +_LOGGER = logging.getLogger('test-epg') + + +class TestEpg(unittest.TestCase): + def __init__(self, *args, **kwargs): + super(TestEpg, self).__init__(*args, **kwargs) + self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) + + def test_vier_today(self): + epg = EpgApi() + programs = epg.get_epg('vier', date.today().strftime('%Y-%m-%d')) + self.assertIsInstance(programs, list) + self.assertIsInstance(programs[0], EpgProgram) + + def test_vijf_today(self): + epg = EpgApi() + programs = epg.get_epg('vijf', date.today().strftime('%Y-%m-%d')) + self.assertIsInstance(programs, list) + self.assertIsInstance(programs[0], EpgProgram) + + def test_zes_today(self): + epg = EpgApi() + programs = epg.get_epg('zes', date.today().strftime('%Y-%m-%d')) + self.assertIsInstance(programs, list) + self.assertIsInstance(programs[0], EpgProgram) + + def test_unknown_today(self): + epg = EpgApi() + with self.assertRaises(Exception): + epg.get_epg('vtm', date.today().strftime('%Y-%m-%d')) + + def test_vier_out_of_range(self): + epg = EpgApi() + programs = epg.get_epg('vier', '2020-01-01') + self.assertEqual(programs, []) + + def test_play_video_from_epg(self): + epg = EpgApi() + epg_programs = epg.get_epg('vier', date.today().strftime('%Y-%m-%d')) + epg_program = [program for program in epg_programs if program.video_url][0] + + # Lookup the Episode data since we don't have an UUID + api = ContentApi(self._auth.get_token()) + episode = api.get_episode(epg_program.channel, epg_program.video_url) + self.assertIsInstance(episode, Episode) + + # Get stream based on the Episode's UUID + video = api.get_stream(episode.channel, episode.uuid) + self.assertTrue(video) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_search.py b/test/test_search.py new file mode 100644 index 0000000..dab23f3 --- /dev/null +++ b/test/test_search.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +""" Tests for EPG API """ + +# pylint: disable=missing-docstring,no-self-use + +from __future__ import absolute_import, division, print_function, unicode_literals + +import logging +import unittest + +from resources.lib.viervijfzes.content import Program +from resources.lib.viervijfzes.search import SearchApi + +_LOGGER = logging.getLogger('test-search') + + +class TestSearch(unittest.TestCase): + def __init__(self, *args, **kwargs): + super(TestSearch, self).__init__(*args, **kwargs) + + def test_search(self): + search = SearchApi() + programs = search.search('de mol') + self.assertIsInstance(programs, list) + self.assertIsInstance(programs[0], Program) + + def test_search_empty(self): + search = SearchApi() + programs = search.search('') + self.assertIsInstance(programs, list) + + def test_search_space(self): + search = SearchApi() + programs = search.search(' ') + self.assertIsInstance(programs, list) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/userdata/credentials.json.example b/test/userdata/credentials.json.example new file mode 100644 index 0000000..a11ffea --- /dev/null +++ b/test/userdata/credentials.json.example @@ -0,0 +1,4 @@ +{ + "username": "username", + "password": "password" +} diff --git a/test/userdata/global_settings.json b/test/userdata/global_settings.json new file mode 100644 index 0000000..74d1c53 --- /dev/null +++ b/test/userdata/global_settings.json @@ -0,0 +1,6 @@ +{ + "locale.language": "resource.language.nl_nl", + "network.bandwidth": 0, + "network.usehttpproxy": false, + "videolibrary.showallitems": true +} diff --git a/test/xbmc.py b/test/xbmc.py new file mode 100644 index 0000000..8dd1c46 --- /dev/null +++ b/test/xbmc.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +''' This file implements the Kodi xbmc module, either using stubs or alternative functionality ''' + +# pylint: disable=invalid-name,no-self-use,unused-argument + +from __future__ import absolute_import, division, print_function, unicode_literals + +import json +import os +import time + +from xbmcextra import global_settings, import_language + +LOGDEBUG = 0 +LOGERROR = 4 +LOGFATAL = 6 +LOGINFO = 1 +LOGNONE = 7 +LOGNOTICE = 2 +LOGSEVERE = 5 +LOGWARNING = 3 + +LOG_MAPPING = { + LOGDEBUG: 'Debug', + LOGERROR: 'Error', + LOGFATAL: 'Fatal', + LOGINFO: 'Info', + LOGNONE: 'None', + LOGNOTICE: 'Notice', + LOGSEVERE: 'Severe', + LOGWARNING: 'Warning', +} + +INFO_LABELS = { + 'System.BuildVersion': '18.2', +} + +REGIONS = { + 'datelong': '%A, %e %B %Y', + 'dateshort': '%Y-%m-%d', +} + +GLOBAL_SETTINGS = global_settings() +PO = import_language(language=GLOBAL_SETTINGS.get('locale.language')) + + +def to_unicode(text, encoding='utf-8'): + """ Force text to unicode """ + return text.decode(encoding) if isinstance(text, bytes) else text + + +def from_unicode(text, encoding='utf-8'): + """ Force unicode to text """ + import sys + if sys.version_info.major == 2 and isinstance(text, unicode): # noqa: F821; pylint: disable=undefined-variable + return text.encode(encoding) + return text + + +class Keyboard: + ''' A stub implementation of the xbmc Keyboard class ''' + + def __init__(self, line='', heading=''): + ''' A stub constructor for the xbmc Keyboard class ''' + + def doModal(self, autoclose=0): + ''' A stub implementation for the xbmc Keyboard class doModal() method ''' + + def isConfirmed(self): + ''' A stub implementation for the xbmc Keyboard class isConfirmed() method ''' + return True + + def getText(self): + ''' A stub implementation for the xbmc Keyboard class getText() method ''' + return 'test' + + +class Monitor: + ''' A stub implementation of the xbmc Monitor class ''' + + def __init__(self, line='', heading=''): + ''' A stub constructor for the xbmc Monitor class ''' + + def abortRequested(self): + ''' A stub implementation for the xbmc Keyboard class abortRequested() method ''' + return False + + def waitForAbort(self, timeout=None): + ''' A stub implementation for the xbmc Keyboard class waitForAbort() method ''' + return False + + +class Player: + ''' A stub implementation of the xbmc Player class ''' + + def __init__(self): + self._count = 0 + + def play(self, item='', listitem=None, windowed=False, startpos=-1): + ''' A stub implementation for the xbmc Player class play() method ''' + return + + def isPlaying(self): + ''' A stub implementation for the xbmc Player class isPlaying() method ''' + # Return True four times out of five + self._count += 1 + return bool(self._count % 5 != 0) + + def setSubtitles(self, subtitleFile): + ''' A stub implementation for the xbmc Player class setSubtitles() method ''' + return + + def showSubtitles(self, visible): + ''' A stub implementation for the xbmc Player class showSubtitles() method ''' + return + + def getTotalTime(self): + ''' A stub implementation for the xbmc Player class getTotalTime() method ''' + return 0 + + def getTime(self): + ''' A stub implementation for the xbmc Player class getTime() method ''' + return 0 + + def getVideoInfoTag(self): + ''' A stub implementation for the xbmc Player class getVideoInfoTag() method ''' + return VideoInfoTag() + + def getPlayingFile(self): + ''' A stub implementation for the xbmc Player class getPlayingFile() method ''' + return '' + +class VideoInfoTag: + ''' A stub implementation of the xbmc VideoInfoTag class ''' + + def __init__(self): + ''' A stub constructor for the xbmc VideoInfoTag class ''' + + def getSeason(self): + ''' A stub implementation for the xbmc VideoInfoTag class getSeason() method ''' + return 0 + + def getEpisode(self): + ''' A stub implementation for the xbmc VideoInfoTag class getEpisode() method ''' + return 0 + + def getTVShowTitle(self): + ''' A stub implementation for the xbmc VideoInfoTag class getTVShowTitle() method ''' + return '' + + def getPlayCount(self): + ''' A stub implementation for the xbmc VideoInfoTag class getPlayCount() method ''' + return 0 + + def getRating(self): + ''' A stub implementation for the xbmc VideoInfoTag class getRating() method ''' + return 0 + + +def executebuiltin(string, wait=False): # pylint: disable=unused-argument + ''' A stub implementation of the xbmc executebuiltin() function ''' + return + + +def executeJSONRPC(jsonrpccommand): + ''' A reimplementation of the xbmc executeJSONRPC() function ''' + command = json.loads(jsonrpccommand) + if command.get('method') == 'Settings.GetSettingValue': + key = command.get('params').get('setting') + return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(value=GLOBAL_SETTINGS.get(key)))) + if command.get('method') == 'Addons.GetAddonDetails': + if command.get('params', {}).get('addonid') == 'script.module.inputstreamhelper': + return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(addon=dict(enabled='true', version='0.3.5')))) + return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(addon=dict(enabled='true', version='1.2.3')))) + if command.get('method') == 'Textures.GetTextures': + return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(textures=[dict(cachedurl="", imagehash="", lasthashcheck="", textureid=4837, url="")]))) + if command.get('method') == 'Textures.RemoveTexture': + return json.dumps(dict(id=1, jsonrpc='2.0', result="OK")) + log("executeJSONRPC does not implement method '{method}'".format(**command), 'Error') + return json.dumps(dict(error=dict(code=-1, message='Not implemented'), id=1, jsonrpc='2.0')) + + +def getCondVisibility(string): + ''' A reimplementation of the xbmc getCondVisibility() function ''' + if string == 'system.platform.android': + return False + return True + + +def getInfoLabel(key): + ''' A reimplementation of the xbmc getInfoLabel() function ''' + return INFO_LABELS.get(key) + + +def getLocalizedString(msgctxt): + ''' A reimplementation of the xbmc getLocalizedString() function ''' + for entry in PO: + if entry.msgctxt == '#%s' % msgctxt: + return entry.msgstr or entry.msgid + if int(msgctxt) >= 30000: + log('Unable to translate #{msgctxt}'.format(msgctxt=msgctxt), LOGERROR) + return '<Untranslated>' + + +def getRegion(key): + ''' A reimplementation of the xbmc getRegion() function ''' + return REGIONS.get(key) + + +def log(msg, level=LOGINFO): + ''' A reimplementation of the xbmc log() function ''' + if level in (LOGERROR, LOGFATAL): + print('\033[31;1m%s: \033[32;0m%s\033[0;39m' % (LOG_MAPPING.get(level), to_unicode(msg))) + if level == LOGFATAL: + raise Exception(msg) + elif level in (LOGWARNING, LOGNOTICE): + print('\033[33;1m%s: \033[32;0m%s\033[0;39m' % (LOG_MAPPING.get(level), to_unicode(msg))) + else: + print('\033[32;1m%s: \033[32;0m%s\033[0;39m' % (LOG_MAPPING.get(level), to_unicode(msg))) + + +def setContent(self, content): + ''' A stub implementation of the xbmc setContent() function ''' + return + + +def sleep(seconds): + ''' A reimplementation of the xbmc sleep() function ''' + time.sleep(seconds) + + +def translatePath(path): + ''' A stub implementation of the xbmc translatePath() function ''' + if path.startswith('special://home'): + return path.replace('special://home', os.path.join(os.getcwd(), 'test/')) + if path.startswith('special://masterprofile'): + return path.replace('special://masterprofile', os.path.join(os.getcwd(), 'test/userdata/')) + if path.startswith('special://profile'): + return path.replace('special://profile', os.path.join(os.getcwd(), 'test/userdata/')) + if path.startswith('special://userdata'): + return path.replace('special://userdata', os.path.join(os.getcwd(), 'test/userdata/')) + return path diff --git a/test/xbmcaddon.py b/test/xbmcaddon.py new file mode 100644 index 0000000..edc4b3b --- /dev/null +++ b/test/xbmcaddon.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +''' This file implements the Kodi xbmcaddon module, either using stubs or alternative functionality ''' + +# pylint: disable=invalid-name + +from __future__ import absolute_import, division, print_function, unicode_literals +from xbmc import getLocalizedString +from xbmcextra import ADDON_INFO, ADDON_ID, addon_settings, global_settings, import_language + +GLOBAL_SETTINGS = global_settings() +ADDON_SETTINGS = addon_settings() +PO = import_language(language=GLOBAL_SETTINGS.get('locale.language')) + + +class Addon: + ''' A reimplementation of the xbmcaddon Addon class ''' + + def __init__(self, id=ADDON_ID): # pylint: disable=redefined-builtin + ''' A stub constructor for the xbmcaddon Addon class ''' + self.id = id + + def getAddonInfo(self, key): + ''' A working implementation for the xbmcaddon Addon class getAddonInfo() method ''' + stub_info = dict(id=self.id, name=self.id, version='2.3.4', type='kodi.inputstream', profile='special://userdata', path='special://userdata') + # Add stub_info values to ADDON_INFO when missing (e.g. path and profile) + addon_info = dict(stub_info, **ADDON_INFO) + return addon_info.get(self.id, stub_info).get(key) + + @staticmethod + def getLocalizedString(msgctxt): + ''' A working implementation for the xbmcaddon Addon class getLocalizedString() method ''' + return getLocalizedString(msgctxt) + + def getSetting(self, key): + ''' A working implementation for the xbmcaddon Addon class getSetting() method ''' + return ADDON_SETTINGS.get(self.id, {}).get(key, '') + + @staticmethod + def openSettings(): + ''' A stub implementation for the xbmcaddon Addon class openSettings() method ''' + + def setSetting(self, key, value): + ''' A stub implementation for the xbmcaddon Addon class setSetting() method ''' + if not ADDON_SETTINGS.get(self.id): + ADDON_SETTINGS[self.id] = dict() + ADDON_SETTINGS[self.id][key] = value + # NOTE: Disable actual writing as it is no longer needed for testing + # with open('test/userdata/addon_settings.json', 'w') as fd: + # json.dump(filtered_settings, fd, sort_keys=True, indent=4) diff --git a/test/xbmcextra.py b/test/xbmcextra.py new file mode 100644 index 0000000..317c5dc --- /dev/null +++ b/test/xbmcextra.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +''' Extra functions for testing ''' + +# pylint: disable=invalid-name + +from __future__ import absolute_import, division, print_function, unicode_literals + +import os +import xml.etree.ElementTree as ET + +import polib + + +def kodi_to_ansi(string): + ''' Convert Kodi format tags to ANSI codes ''' + if string is None: + return None + string = string.replace('[B]', '\033[1m') + string = string.replace('[/B]', '\033[21m') + string = string.replace('[I]', '\033[3m') + string = string.replace('[/I]', '\033[23m') + string = string.replace('[COLOR gray]', '\033[30;1m') + string = string.replace('[COLOR red]', '\033[31m') + string = string.replace('[COLOR green]', '\033[32m') + string = string.replace('[COLOR yellow]', '\033[33m') + string = string.replace('[COLOR blue]', '\033[34m') + string = string.replace('[COLOR purple]', '\033[35m') + string = string.replace('[COLOR cyan]', '\033[36m') + string = string.replace('[COLOR white]', '\033[37m') + string = string.replace('[/COLOR]', '\033[39;0m') + return string + + +def uri_to_path(uri): + ''' Shorten a plugin URI to just the path ''' + if uri is None: + return None + return ' \033[33m→ \033[34m%s\033[39;0m' % uri.replace('plugin://' + ADDON_ID, '') + + +def read_addon_xml(path): + ''' Parse the addon.xml and return an info dictionary ''' + info = dict( + path='./', # '/storage/.kodi/addons/plugin.video.viervijfzes + profile='special://userdata', # 'special://profile/addon_data/plugin.video.viervijfzes/', + type='xbmc.python.pluginsource', + ) + + tree = ET.parse(path) + root = tree.getroot() + + info.update(root.attrib) # Add 'id', 'name' and 'version' + info['author'] = info.pop('provider-name') + + for child in root: + if child.attrib.get('point') != 'xbmc.addon.metadata': + continue + for grandchild in child: + # Handle assets differently + if grandchild.tag == 'assets': + for asset in grandchild: + info[asset.tag] = asset.text + continue + # Not in English ? Drop it + if grandchild.attrib.get('lang', 'en_GB') != 'en_GB': + continue + # Add metadata + info[grandchild.tag] = grandchild.text + + return {info['name']: info} + + +def global_settings(): + ''' Use the global_settings file ''' + import json + try: + with open('test/userdata/global_settings.json') as f: + settings = json.load(f) + except OSError as e: + print("Error: Cannot use 'test/userdata/global_settings.json' : %s" % e) + settings = { + 'locale.language': 'resource.language.en_gb', + 'network.bandwidth': 0, + } + + if 'PROXY_SERVER' in os.environ: + settings['network.usehttpproxy'] = True + settings['network.httpproxytype'] = 0 + print('Using proxy server from environment variable PROXY_SERVER') + settings['network.httpproxyserver'] = os.environ.get('PROXY_SERVER') + if 'PROXY_PORT' in os.environ: + print('Using proxy server from environment variable PROXY_PORT') + settings['network.httpproxyport'] = os.environ.get('PROXY_PORT') + if 'PROXY_USERNAME' in os.environ: + print('Using proxy server from environment variable PROXY_USERNAME') + settings['network.httpproxyusername'] = os.environ.get('PROXY_USERNAME') + if 'PROXY_PASSWORD' in os.environ: + print('Using proxy server from environment variable PROXY_PASSWORD') + settings['network.httpproxypassword'] = os.environ.get('PROXY_PASSWORD') + return settings + + +def addon_settings(): + ''' Use the addon_settings file ''' + import json + try: + with open('test/userdata/addon_settings.json') as f: + settings = json.load(f) + except OSError as e: + print("Error: Cannot use 'test/userdata/addon_settings.json' : %s" % e) + settings = {} + + # Read credentials from environment or credentials.json + if 'ADDON_USERNAME' in os.environ and 'ADDON_PASSWORD' in os.environ: + print('Using credentials from the environment variables ADDON_USERNAME and ADDON_PASSWORD') + settings[ADDON_ID]['username'] = os.environ.get('ADDON_USERNAME') + settings[ADDON_ID]['password'] = os.environ.get('ADDON_PASSWORD') + settings[ADDON_ID]['profile'] = os.environ.get('ADDON_PROFILE') + elif os.path.exists('test/userdata/credentials.json'): + print('Using credentials from test/userdata/credentials.json') + with open('test/userdata/credentials.json') as f: + credentials = json.load(f) + settings[ADDON_ID].update(credentials) + else: + print("Error: Cannot use 'test/userdata/credentials.json'") + return settings + + +def import_language(language): + ''' Process the language.po file ''' + return polib.pofile('resources/language/{language}/strings.po'.format(language=language)) + + +ADDON_INFO = read_addon_xml('addon.xml') +ADDON_ID = next(iter(list(ADDON_INFO.values()))).get('id') diff --git a/test/xbmcgui.py b/test/xbmcgui.py new file mode 100644 index 0000000..343679e --- /dev/null +++ b/test/xbmcgui.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +''' This file implements the Kodi xbmcgui module, either using stubs or alternative functionality ''' + +# pylint: disable=invalid-name,too-many-arguments,unused-argument + +from __future__ import absolute_import, division, print_function, unicode_literals + +from xbmcextra import kodi_to_ansi + + +class Dialog: + ''' A reimplementation of the xbmcgui Dialog class ''' + + def __init__(self): + ''' A stub constructor for the xbmcgui Dialog class ''' + + @staticmethod + def notification(heading, message, icon=None, time=None, sound=None): + ''' A working implementation for the xbmcgui Dialog class notification() method ''' + heading = kodi_to_ansi(heading) + message = kodi_to_ansi(message) + print('\033[37;44;1mNOTIFICATION:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message)) + + @staticmethod + def ok(heading, line1, line2=None, line3=None): + ''' A stub implementation for the xbmcgui Dialog class ok() method ''' + heading = kodi_to_ansi(heading) + line1 = kodi_to_ansi(line1) + print('\033[37;44;1mOK:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, line1)) + + @staticmethod + def info(listitem): + ''' A stub implementation for the xbmcgui Dialog class info() method ''' + + @staticmethod + def multiselect(heading, options, autoclose=0, preselect=None, useDetails=False): # pylint: disable=useless-return + ''' A stub implementation for the xbmcgui Dialog class multiselect() method ''' + if preselect is None: + preselect = [] + heading = kodi_to_ansi(heading) + print('\033[37;44;1mMULTISELECT:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, ', '.join(options))) + return None + + @staticmethod + def contextmenu(items): + ''' A stub implementation for the xbmcgui Dialog class contextmenu() method ''' + print('\033[37;44;1mCONTEXTMENU:\033[35;49;1m \033[37;1m%s\033[39;0m' % (', '.join(items))) + return -1 + + @staticmethod + def yesno(heading, line1, line2=None, line3=None, nolabel=None, yeslabel=None, autoclose=0): + ''' A stub implementation for the xbmcgui Dialog class yesno() method ''' + heading = kodi_to_ansi(heading) + line1 = kodi_to_ansi(line1) + print('\033[37;44;1mYESNO:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, line1)) + return True + + @staticmethod + def textviewer(heading, text=None, usemono=None): + ''' A stub implementation for the xbmcgui Dialog class textviewer() method ''' + heading = kodi_to_ansi(heading) + text = kodi_to_ansi(text) + print('\033[37;44;1mTEXTVIEWER:\033[35;49;1m [%s]\n\033[37;1m%s\033[39;0m' % (heading, text)) + + @staticmethod + def browseSingle(type, heading, shares, mask=None, useThumbs=None, treatAsFolder=None, default=None): # pylint: disable=redefined-builtin + ''' A stub implementation for the xbmcgui Dialog class browseSingle() method ''' + print('\033[37;44;1mBROWSESINGLE:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (type, heading)) + return 'special://masterprofile/addon_data/script.module.inputstreamhelper/' + + +class DialogProgress: + ''' A reimplementation of the xbmcgui DialogProgress ''' + + def __init__(self): + ''' A stub constructor for the xbmcgui DialogProgress class ''' + self.percentage = 0 + + @staticmethod + def close(): + ''' A stub implementation for the xbmcgui DialogProgress class close() method ''' + print() + + @staticmethod + def create(heading, line1, line2=None, line3=None): + ''' A stub implementation for the xbmcgui DialogProgress class create() method ''' + heading = kodi_to_ansi(heading) + line1 = kodi_to_ansi(line1) + print('\033[37;44;1mPROGRESS:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, line1)) + + @staticmethod + def iscanceled(): + ''' A stub implementation for the xbmcgui DialogProgress class iscanceled() method ''' + return True + + def update(self, percentage, line1=None, line2=None, line3=None): + ''' A stub implementation for the xbmcgui DialogProgress class update() method ''' + if (percentage - 5) < self.percentage: + return + self.percentage = percentage + line1 = kodi_to_ansi(line1) + line2 = kodi_to_ansi(line2) + line3 = kodi_to_ansi(line3) + if line1 or line2 or line3: + print('\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%] \033[37;1m%s\033[39;0m' % (percentage, line1 or line2 or line3)) + else: + print('\033[1G\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%]\033[39;0m' % (percentage), end='') + + +class DialogProgressBG: + ''' A reimplementation of the xbmcgui DialogProgressBG ''' + + def __init__(self): + ''' A stub constructor for the xbmcgui DialogProgressBG class ''' + self.percentage = 0 + + @staticmethod + def close(): + ''' A stub implementation for the xbmcgui DialogProgressBG class close() method ''' + print() + + @staticmethod + def create(heading, message): + ''' A stub implementation for the xbmcgui DialogProgressBG class create() method ''' + heading = kodi_to_ansi(heading) + message = kodi_to_ansi(message) + print('\033[37;44;1mPROGRESS:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message)) + + @staticmethod + def isfinished(): + ''' A stub implementation for the xbmcgui DialogProgressBG class isfinished() method ''' + + def update(self, percentage, heading=None, message=None): + ''' A stub implementation for the xbmcgui DialogProgressBG class update() method ''' + if (percentage - 5) < self.percentage: + return + self.percentage = percentage + message = kodi_to_ansi(message) + if message: + print('\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%] \033[37;1m%s\033[39;0m' % (percentage, message)) + else: + print('\033[1G\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%]\033[39;0m' % (percentage), end='') + + +class DialogBusy: + ''' A reimplementation of the xbmcgui DialogBusy ''' + + def __init__(self): + ''' A stub constructor for the xbmcgui DialogBusy class ''' + + @staticmethod + def close(): + ''' A stub implementation for the xbmcgui DialogBusy class close() method ''' + + @staticmethod + def create(): + ''' A stub implementation for the xbmcgui DialogBusy class create() method ''' + + +class ListItem: + ''' A reimplementation of the xbmcgui ListItem class ''' + + def __init__(self, label='', label2='', iconImage='', thumbnailImage='', path='', offscreen=False): + ''' A stub constructor for the xbmcgui ListItem class ''' + self.label = kodi_to_ansi(label) + self.label2 = kodi_to_ansi(label2) + self.path = path + + @staticmethod + def addContextMenuItems(items, replaceItems=False): + ''' A stub implementation for the xbmcgui ListItem class addContextMenuItems() method ''' + return + + @staticmethod + def addStreamInfo(stream_type, stream_values): + ''' A stub implementation for the xbmcgui LitItem class addStreamInfo() method ''' + return + + @staticmethod + def setArt(key): + ''' A stub implementation for the xbmcgui ListItem class setArt() method ''' + return + + @staticmethod + def setContentLookup(enable): + ''' A stub implementation for the xbmcgui ListItem class setContentLookup() method ''' + return + + @staticmethod + def setInfo(type, infoLabels): # pylint: disable=redefined-builtin + ''' A stub implementation for the xbmcgui ListItem class setInfo() method ''' + return + + @staticmethod + def setIsFolder(isFolder): + ''' A stub implementation for the xbmcgui ListItem class setIsFolder() method ''' + return + + @staticmethod + def setMimeType(mimetype): + ''' A stub implementation for the xbmcgui ListItem class setMimeType() method ''' + return + + def setPath(self, path): + ''' A stub implementation for the xbmcgui ListItem class setPath() method ''' + self.path = path + + @staticmethod + def setProperty(key, value): + ''' A stub implementation for the xbmcgui ListItem class setProperty() method ''' + return + + @staticmethod + def setProperties(dictionary): + ''' A stub implementation for the xbmcgui ListItem class setProperties() method ''' + return + + @staticmethod + def setSubtitles(subtitleFiles): + ''' A stub implementation for the xbmcgui ListItem class setSubtitles() method ''' + return diff --git a/test/xbmcplugin.py b/test/xbmcplugin.py new file mode 100644 index 0000000..b5da85a --- /dev/null +++ b/test/xbmcplugin.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +''' This file implements the Kodi xbmcplugin module, either using stubs or alternative functionality ''' + +# pylint: disable=invalid-name,unused-argument +from __future__ import absolute_import, division, print_function, unicode_literals + +from xbmc import log, LOGERROR +from xbmcextra import kodi_to_ansi, uri_to_path + +try: # Python 3 + from urllib.error import HTTPError + from urllib.request import Request, urlopen +except ImportError: # Python 2 + from urllib2 import HTTPError, Request, urlopen + +SORT_METHOD_NONE = 0 +SORT_METHOD_LABEL = 1 +SORT_METHOD_LABEL_IGNORE_THE = 2 +SORT_METHOD_DATE = 3 +SORT_METHOD_SIZE = 4 +SORT_METHOD_FILE = 5 +SORT_METHOD_DRIVE_TYPE = 6 +SORT_METHOD_TRACKNUM = 7 +SORT_METHOD_DURATION = 8 +SORT_METHOD_TITLE = 9 +SORT_METHOD_TITLE_IGNORE_THE = 10 +SORT_METHOD_ARTIST = 11 +SORT_METHOD_ARTIST_AND_YEAR = 12 +SORT_METHOD_ARTIST_IGNORE_THE = 13 +SORT_METHOD_ALBUM = 14 +SORT_METHOD_ALBUM_IGNORE_THE = 15 +SORT_METHOD_GENRE = 16 +SORT_METHOD_COUNTRY = 17 +SORT_METHOD_VIDEO_YEAR = 18 # This is SORT_METHOD_YEAR in Kodi +SORT_METHOD_VIDEO_RATING = 19 +SORT_METHOD_VIDEO_USER_RATING = 20 +SORT_METHOD_DATEADDED = 21 +SORT_METHOD_PROGRAM_COUNT = 22 +SORT_METHOD_PLAYLIST_ORDER = 23 +SORT_METHOD_EPISODE = 24 +SORT_METHOD_VIDEO_TITLE = 25 +SORT_METHOD_VIDEO_SORT_TITLE = 26 +SORT_METHOD_VIDEO_SORT_TITLE_IGNORE_THE = 27 +SORT_METHOD_PRODUCTIONCODE = 28 +SORT_METHOD_SONG_RATING = 29 +SORT_METHOD_SONG_USER_RATING = 30 +SORT_METHOD_MPAA_RATING = 31 +SORT_METHOD_VIDEO_RUNTIME = 32 +SORT_METHOD_STUDIO = 33 +SORT_METHOD_STUDIO_IGNORE_THE = 34 +SORT_METHOD_FULLPATH = 35 +SORT_METHOD_LABEL_IGNORE_FOLDERS = 36 +SORT_METHOD_LASTPLAYED = 37 +SORT_METHOD_PLAYCOUNT = 38 +SORT_METHOD_LISTENERS = 39 +SORT_METHOD_UNSORTED = 40 +SORT_METHOD_CHANNEL = 41 +SORT_METHOD_CHANNEL_NUMBER = 42 +SORT_METHOD_BITRATE = 43 +SORT_METHOD_DATE_TAKEN = 44 + + +def addDirectoryItem(handle, path, listitem, isFolder=False): + ''' A reimplementation of the xbmcplugin addDirectoryItems() function ''' + label = kodi_to_ansi(listitem.label) + path = uri_to_path(path) if path else '' + bullet = '»' if isFolder else '·' + print('{bullet} {label}{path}'.format(bullet=bullet, label=label, path=path)) + return True + + +def addDirectoryItems(handle, listing, length): + ''' A reimplementation of the xbmcplugin addDirectoryItems() function ''' + for item in listing: + addDirectoryItem(handle, item[0], item[1], item[2]) + return True + + +def addSortMethod(handle, sortMethod): + ''' A stub implementation of the xbmcplugin addSortMethod() function ''' + + +def endOfDirectory(handle, succeeded=True, updateListing=True, cacheToDisc=True): + ''' A stub implementation of the xbmcplugin endOfDirectory() function ''' + print(kodi_to_ansi('[B]-=( [COLOR cyan]--------[/COLOR] )=-[/B]')) + + +def setContent(handle, content): + ''' A stub implementation of the xbmcplugin setContent() function ''' + + +def setPluginFanart(handle, image, color1=None, color2=None, color3=None): + ''' A stub implementation of the xbmcplugin setPluginFanart() function ''' + + +def setPluginCategory(handle, category): + ''' A reimplementation of the xbmcplugin setPluginCategory() function ''' + print(kodi_to_ansi('[B]-=( [COLOR cyan]%s[/COLOR] )=-[/B]' % category)) + + +def setResolvedUrl(handle, succeeded, listitem): + ''' A stub implementation of the xbmcplugin setResolvedUrl() function ''' + request = Request(listitem.path) + request.get_method = lambda: 'HEAD' + try: + response = urlopen(request) + log('Stream playing successfully: %s' % response.code) + except HTTPError as exc: + log('Playing stream returned: %s' % exc, LOGERROR) diff --git a/test/xbmcvfs.py b/test/xbmcvfs.py new file mode 100644 index 0000000..0daf157 --- /dev/null +++ b/test/xbmcvfs.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com> +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +''' This file implements the Kodi xbmcvfs module, either using stubs or alternative functionality ''' + +# pylint: disable=invalid-name + +from __future__ import absolute_import, division, print_function, unicode_literals +import os + + +def File(path, flags='r'): + ''' A reimplementation of the xbmcvfs File() function ''' + return open(path, flags) + + +def Stat(path): + ''' A reimplementation of the xbmcvfs Stat() function ''' + + class stat: + ''' A reimplementation of the xbmcvfs stat class ''' + + def __init__(self, path): + ''' The constructor xbmcvfs stat class ''' + self._stat = os.stat(path) + + def st_mtime(self): + ''' The xbmcvfs stat class st_mtime method ''' + return self._stat.st_mtime + + return stat(path) + + +def delete(path): + ''' A reimplementation of the xbmcvfs delete() function ''' + try: + os.remove(path) + except OSError: + pass + + +def exists(path): + ''' A reimplementation of the xbmcvfs exists() function ''' + return os.path.exists(path) + + +def listdir(path): + ''' A reimplementation of the xbmcvfs listdir() function ''' + files = [] + dirs = [] + for filename in os.listdir(path): + if os.path.isfile(filename): + files.append(filename) + if os.path.isdir(filename): + dirs.append(filename) + return dirs, files + + +def mkdir(path): + ''' A reimplementation of the xbmcvfs mkdir() function ''' + return os.mkdir(path) + + +def mkdirs(path): + ''' A reimplementation of the xbmcvfs mkdirs() function ''' + return os.makedirs(path) + + +def rmdir(path): + ''' A reimplementation of the xbmcvfs rmdir() function ''' + return os.rmdir(path) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..ff4f2da --- /dev/null +++ b/tox.ini @@ -0,0 +1,26 @@ +[tox] +envlist = py27,py36,py37,flake8 +skipsdist = True +skip_missing_interpreters = True + +[testenv:flake8] +commands = + - {envbindir}/flake8 +deps = + flake8 + flake8-coding + flake8-future-import + +[flake8] +builtins = func +max-line-length = 160 +ignore = FI13,FI50,FI51,FI53,FI54,W503 +require-code = True +min-version = 2.7 + +[pytest] +filterwarnings = default + +[pycodestyle] +max-line-length = 160 +