diff --git a/.gitignore b/.gitignore
index fde90de..4922d5e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@
# Local install output
/install/
+
+# Build outputs
+/dist/
diff --git a/dist/plugin.video.viewit-0.1.46.zip b/dist/plugin.video.viewit-0.1.46.zip
deleted file mode 100644
index 8314172..0000000
Binary files a/dist/plugin.video.viewit-0.1.46.zip and /dev/null differ
diff --git a/dist/plugin.video.viewit/LICENSE.txt b/dist/plugin.video.viewit/LICENSE.txt
deleted file mode 100644
index 678a7ba..0000000
--- a/dist/plugin.video.viewit/LICENSE.txt
+++ /dev/null
@@ -1,598 +0,0 @@
- 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.
-
- 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.
-
-
- Copyright (C)
-
- 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 .
-
diff --git a/dist/plugin.video.viewit/NOTICE.txt b/dist/plugin.video.viewit/NOTICE.txt
deleted file mode 100644
index b7d030d..0000000
--- a/dist/plugin.video.viewit/NOTICE.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-Copyright (C) 2026 ViewIt contributors
-
-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.
-
-This Kodi addon depends on `script.module.resolveurl`.
diff --git a/dist/plugin.video.viewit/README_DEPENDENCIES.txt b/dist/plugin.video.viewit/README_DEPENDENCIES.txt
deleted file mode 100644
index f9c1c29..0000000
--- a/dist/plugin.video.viewit/README_DEPENDENCIES.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-Abhaengigkeiten fuer Serienstream-Plugin:
-- Python-Paket: requests
-- Python-Paket: beautifulsoup4
-- Kodi-Addon: script.module.resolveurl
-
-Hinweis:
-Kodi nutzt sein eigenes Python. Installiere Pakete in die Kodi-Python-Umgebung
-oder nutze ein Kodi-Addon, das Python-Pakete mitliefert.
-
-Lizenz:
-Dieses Kodi-Addon ist GPL-3.0-or-later (siehe `LICENSE.txt`).
diff --git a/dist/plugin.video.viewit/addon.xml b/dist/plugin.video.viewit/addon.xml
deleted file mode 100644
index 92f80c7..0000000
--- a/dist/plugin.video.viewit/addon.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
-
-
-
- video
-
-
- ViewIt Kodi Plugin
- Streaming-Addon für Streamingseiten: Suche, Staffeln/Episoden und Wiedergabe.
-
- icon.png
-
- GPL-3.0-or-later
- all
-
-
diff --git a/dist/plugin.video.viewit/default.py b/dist/plugin.video.viewit/default.py
deleted file mode 100644
index 191f345..0000000
--- a/dist/plugin.video.viewit/default.py
+++ /dev/null
@@ -1,2417 +0,0 @@
-#!/usr/bin/env python3
-"""ViewIt Kodi-Addon Einstiegspunkt.
-
-Dieses Modul ist der Router fuer die Kodi-Navigation: es rendert Menues,
-ruft Plugin-Implementierungen auf und startet die Wiedergabe.
-"""
-
-from __future__ import annotations
-
-import asyncio
-from contextlib import contextmanager
-from datetime import datetime
-import importlib.util
-import inspect
-import os
-import re
-import sys
-from pathlib import Path
-from types import ModuleType
-from urllib.parse import parse_qs, urlencode
-
-try: # pragma: no cover - Kodi runtime
- import xbmc # type: ignore[import-not-found]
- import xbmcaddon # type: ignore[import-not-found]
- import xbmcgui # type: ignore[import-not-found]
- import xbmcplugin # type: ignore[import-not-found]
- import xbmcvfs # type: ignore[import-not-found]
-except ImportError: # pragma: no cover - allow importing outside Kodi (e.g. linting)
- xbmc = None
- xbmcaddon = None
- xbmcgui = None
- xbmcplugin = None
- xbmcvfs = None
-
- class _XbmcStub:
- LOGDEBUG = 0
- LOGINFO = 1
- LOGWARNING = 2
-
- @staticmethod
- def log(message: str, level: int = 1) -> None:
- print(f"[KodiStub:{level}] {message}")
-
- class Player:
- def play(self, item: str, listitem: object | None = None) -> None:
- print(f"[KodiStub] play: {item}")
-
- class _XbmcGuiStub:
- INPUT_ALPHANUM = 0
- NOTIFICATION_INFO = 0
-
- class Dialog:
- def input(self, heading: str, type: int = 0) -> str:
- raise RuntimeError("xbmcgui ist nicht verfuegbar (KodiStub).")
-
- def select(self, heading: str, options: list[str]) -> int:
- raise RuntimeError("xbmcgui ist nicht verfuegbar (KodiStub).")
-
- def notification(self, heading: str, message: str, icon: int = 0, time: int = 0) -> None:
- print(f"[KodiStub] notification: {heading}: {message}")
-
- class ListItem:
- def __init__(self, label: str = "", path: str = "") -> None:
- self._label = label
- self._path = path
-
- def setInfo(self, type: str, infoLabels: dict[str, str]) -> None:
- return
-
- class _XbmcPluginStub:
- @staticmethod
- def addDirectoryItem(*, handle: int, url: str, listitem: object, isFolder: bool) -> None:
- print(f"[KodiStub] addDirectoryItem: {url}")
-
- @staticmethod
- def endOfDirectory(handle: int) -> None:
- print(f"[KodiStub] endOfDirectory: {handle}")
-
- @staticmethod
- def setPluginCategory(handle: int, category: str) -> None:
- print(f"[KodiStub] category: {category}")
-
- xbmc = _XbmcStub()
- xbmcgui = _XbmcGuiStub()
- xbmcplugin = _XbmcPluginStub()
-
-from plugin_interface import BasisPlugin
-from tmdb import TmdbCastMember, fetch_tv_episode_credits, lookup_movie, lookup_tv_season, lookup_tv_season_summary, lookup_tv_show
-
-PLUGIN_DIR = Path(__file__).with_name("plugins")
-_PLUGIN_CACHE: dict[str, BasisPlugin] | None = None
-_TMDB_CACHE: dict[str, tuple[dict[str, str], dict[str, str]]] = {}
-_TMDB_CAST_CACHE: dict[str, list[TmdbCastMember]] = {}
-_TMDB_ID_CACHE: dict[str, int] = {}
-_TMDB_SEASON_CACHE: dict[tuple[int, int, str, str], dict[int, tuple[dict[str, str], dict[str, str]]]] = {}
-_TMDB_SEASON_SUMMARY_CACHE: dict[tuple[int, int, str, str], tuple[dict[str, str], dict[str, str]]] = {}
-_TMDB_EPISODE_CAST_CACHE: dict[tuple[int, int, int, str], list[TmdbCastMember]] = {}
-_TMDB_LOG_PATH: str | None = None
-_GENRE_TITLES_CACHE: dict[tuple[str, str], list[str]] = {}
-_ADDON_INSTANCE = None
-_PLAYSTATE_CACHE: dict[str, dict[str, object]] | None = None
-WATCHED_THRESHOLD = 0.9
-
-
-def _tmdb_prefetch_concurrency() -> int:
- """Max number of concurrent TMDB lookups when prefetching metadata for lists."""
- try:
- raw = _get_setting_string("tmdb_prefetch_concurrency").strip()
- value = int(raw) if raw else 6
- except Exception:
- value = 6
- return max(1, min(20, value))
-
-
-def _log(message: str, level: int = xbmc.LOGINFO) -> None:
- xbmc.log(f"[ViewIt] {message}", level)
-
-
-def _busy_open() -> None:
- try: # pragma: no cover - Kodi runtime
- if xbmc is not None and hasattr(xbmc, "executebuiltin"):
- xbmc.executebuiltin("ActivateWindow(busydialognocancel)")
- except Exception:
- pass
-
-
-def _busy_close() -> None:
- try: # pragma: no cover - Kodi runtime
- if xbmc is not None and hasattr(xbmc, "executebuiltin"):
- xbmc.executebuiltin("Dialog.Close(busydialognocancel)")
- xbmc.executebuiltin("Dialog.Close(busydialog)")
- except Exception:
- pass
-
-
-@contextmanager
-def _busy_dialog():
- _busy_open()
- try:
- yield
- finally:
- _busy_close()
-
-
-def _get_handle() -> int:
- return int(sys.argv[1]) if len(sys.argv) > 1 else -1
-
-
-def _set_content(handle: int, content: str) -> None:
- """Hint Kodi about the content type so skins can show watched/resume overlays."""
- content = (content or "").strip()
- if not content:
- return
- try: # pragma: no cover - Kodi runtime
- setter = getattr(xbmcplugin, "setContent", None)
- if callable(setter):
- setter(handle, content)
- except Exception:
- pass
-
-
-def _get_addon():
- global _ADDON_INSTANCE
- if xbmcaddon is None:
- return None
- if _ADDON_INSTANCE is None:
- _ADDON_INSTANCE = xbmcaddon.Addon()
- return _ADDON_INSTANCE
-
-
-def _playstate_key(*, plugin_name: str, title: str, season: str, episode: str) -> str:
- plugin_name = (plugin_name or "").strip()
- title = (title or "").strip()
- season = (season or "").strip()
- episode = (episode or "").strip()
- return f"{plugin_name}\t{title}\t{season}\t{episode}"
-
-
-def _playstate_path() -> str:
- return _get_log_path("playstate.json")
-
-
-def _load_playstate() -> dict[str, dict[str, object]]:
- global _PLAYSTATE_CACHE
- if _PLAYSTATE_CACHE is not None:
- return _PLAYSTATE_CACHE
- path = _playstate_path()
- try:
- if xbmcvfs and xbmcvfs.exists(path):
- handle = xbmcvfs.File(path)
- raw = handle.read()
- handle.close()
- else:
- with open(path, "r", encoding="utf-8") as handle:
- raw = handle.read()
- data = json.loads(raw or "{}")
- if isinstance(data, dict):
- normalized: dict[str, dict[str, object]] = {}
- for key, value in data.items():
- if isinstance(key, str) and isinstance(value, dict):
- normalized[key] = dict(value)
- _PLAYSTATE_CACHE = normalized
- return normalized
- except Exception:
- pass
- _PLAYSTATE_CACHE = {}
- return {}
-
-
-def _save_playstate(state: dict[str, dict[str, object]]) -> None:
- global _PLAYSTATE_CACHE
- _PLAYSTATE_CACHE = state
- path = _playstate_path()
- try:
- payload = json.dumps(state, ensure_ascii=False, sort_keys=True)
- except Exception:
- return
- try:
- if xbmcvfs:
- directory = os.path.dirname(path)
- if directory and not xbmcvfs.exists(directory):
- xbmcvfs.mkdirs(directory)
- handle = xbmcvfs.File(path, "w")
- handle.write(payload)
- handle.close()
- else:
- with open(path, "w", encoding="utf-8") as handle:
- handle.write(payload)
- except Exception:
- return
-
-
-def _get_playstate(key: str) -> dict[str, object]:
- return dict(_load_playstate().get(key, {}) or {})
-
-
-def _set_playstate(key: str, value: dict[str, object]) -> None:
- state = _load_playstate()
- if value:
- state[key] = dict(value)
- else:
- state.pop(key, None)
- _save_playstate(state)
-
-
-def _apply_playstate_to_info(info_labels: dict[str, object], playstate: dict[str, object]) -> dict[str, object]:
- info_labels = dict(info_labels or {})
- watched = bool(playstate.get("watched") or False)
- resume_position = playstate.get("resume_position")
- resume_total = playstate.get("resume_total")
- if watched:
- info_labels["playcount"] = 1
- info_labels.pop("resume_position", None)
- info_labels.pop("resume_total", None)
- else:
- try:
- pos = int(resume_position) if resume_position is not None else 0
- tot = int(resume_total) if resume_total is not None else 0
- except Exception:
- pos, tot = 0, 0
- if pos > 0 and tot > 0:
- info_labels["resume_position"] = pos
- info_labels["resume_total"] = tot
- return info_labels
-
-
-def _time_label(seconds: int) -> str:
- try:
- seconds = int(seconds or 0)
- except Exception:
- seconds = 0
- if seconds <= 0:
- return ""
- hours = seconds // 3600
- minutes = (seconds % 3600) // 60
- secs = seconds % 60
- if hours > 0:
- return f"{hours:02d}:{minutes:02d}:{secs:02d}"
- return f"{minutes:02d}:{secs:02d}"
-
-
-def _label_with_playstate(label: str, playstate: dict[str, object]) -> str:
- watched = bool(playstate.get("watched") or False)
- if watched:
- return f"✓ {label}"
- resume_pos = playstate.get("resume_position")
- try:
- pos = int(resume_pos) if resume_pos is not None else 0
- except Exception:
- pos = 0
- if pos > 0:
- return f"↩ {_time_label(pos)} {label}"
- return label
-
-
-def _title_playstate(plugin_name: str, title: str) -> dict[str, object]:
- return _get_playstate(_playstate_key(plugin_name=plugin_name, title=title, season="", episode=""))
-
-
-def _season_playstate(plugin_name: str, title: str, season: str) -> dict[str, object]:
- return _get_playstate(_playstate_key(plugin_name=plugin_name, title=title, season=season, episode=""))
-
-
-def _get_setting_string(setting_id: str) -> str:
- if xbmcaddon is None:
- return ""
- addon = _get_addon()
- if addon is None:
- return ""
- getter = getattr(addon, "getSettingString", None)
- if callable(getter):
- try:
- return str(getter(setting_id) or "")
- except TypeError:
- return ""
- getter = getattr(addon, "getSetting", None)
- if callable(getter):
- try:
- return str(getter(setting_id) or "")
- except TypeError:
- return ""
- return ""
-
-
-def _get_setting_bool(setting_id: str, *, default: bool = False) -> bool:
- if xbmcaddon is None:
- return default
- addon = _get_addon()
- if addon is None:
- return default
- getter = getattr(addon, "getSettingBool", None)
- if callable(getter):
- # Kodi kann für unbekannte Settings stillschweigend `False` liefern.
- # Damit neue Settings mit `default=True` korrekt funktionieren, prüfen wir auf leeren Raw-Value.
- raw_getter = getattr(addon, "getSetting", None)
- if callable(raw_getter):
- try:
- raw = str(raw_getter(setting_id) or "").strip()
- except TypeError:
- raw = ""
- if raw == "":
- return default
- try:
- return bool(getter(setting_id))
- except TypeError:
- return default
- getter = getattr(addon, "getSetting", None)
- if callable(getter):
- try:
- raw = str(getter(setting_id) or "").strip().lower()
- except TypeError:
- return default
- if raw in {"true", "1", "yes", "on"}:
- return True
- if raw in {"false", "0", "no", "off"}:
- return False
- return default
-
-
-def _apply_video_info(item, info_labels: dict[str, object] | None, cast: list[TmdbCastMember] | None) -> None:
- """Setzt Metadaten bevorzugt via InfoTagVideo (Kodi v20+), mit Fallback auf deprecated APIs."""
-
- if not info_labels and not cast:
- return
-
- info_labels = dict(info_labels or {})
-
- get_tag = getattr(item, "getVideoInfoTag", None)
- tag = None
- if callable(get_tag):
- try:
- tag = get_tag()
- except Exception:
- tag = None
-
- if tag is not None:
- try:
- title = info_labels.get("title") or ""
- plot = info_labels.get("plot") or ""
- mediatype = info_labels.get("mediatype") or ""
- tvshowtitle = info_labels.get("tvshowtitle") or ""
- season = info_labels.get("season")
- episode = info_labels.get("episode")
- rating = info_labels.get("rating")
- votes = info_labels.get("votes")
- duration = info_labels.get("duration")
- playcount = info_labels.get("playcount")
- resume_position = info_labels.get("resume_position")
- resume_total = info_labels.get("resume_total")
-
- setter = getattr(tag, "setTitle", None)
- if callable(setter) and title:
- setter(str(title))
- setter = getattr(tag, "setPlot", None)
- if callable(setter) and plot:
- setter(str(plot))
- setter = getattr(tag, "setMediaType", None)
- if callable(setter) and mediatype:
- setter(str(mediatype))
- setter = getattr(tag, "setTvShowTitle", None)
- if callable(setter) and tvshowtitle:
- setter(str(tvshowtitle))
- setter = getattr(tag, "setSeason", None)
- if callable(setter) and season not in (None, "", 0, "0"):
- setter(int(season)) # type: ignore[arg-type]
- setter = getattr(tag, "setEpisode", None)
- if callable(setter) and episode not in (None, "", 0, "0"):
- setter(int(episode)) # type: ignore[arg-type]
-
- if rating not in (None, "", 0, "0"):
- try:
- rating_f = float(rating) # type: ignore[arg-type]
- except Exception:
- rating_f = 0.0
- if rating_f:
- set_rating = getattr(tag, "setRating", None)
- if callable(set_rating):
- try:
- if votes not in (None, "", 0, "0"):
- set_rating(rating_f, int(votes), "tmdb") # type: ignore[misc]
- else:
- set_rating(rating_f) # type: ignore[misc]
- except Exception:
- try:
- set_rating(rating_f, int(votes or 0), "tmdb", True) # type: ignore[misc]
- except Exception:
- pass
-
- if duration not in (None, "", 0, "0"):
- try:
- duration_i = int(duration) # type: ignore[arg-type]
- except Exception:
- duration_i = 0
- if duration_i:
- set_duration = getattr(tag, "setDuration", None)
- if callable(set_duration):
- try:
- set_duration(duration_i)
- except Exception:
- pass
-
- if playcount not in (None, "", 0, "0"):
- try:
- playcount_i = int(playcount) # type: ignore[arg-type]
- except Exception:
- playcount_i = 0
- if playcount_i:
- set_playcount = getattr(tag, "setPlaycount", None)
- if callable(set_playcount):
- try:
- set_playcount(playcount_i)
- except Exception:
- pass
-
- try:
- pos = int(resume_position) if resume_position is not None else 0
- tot = int(resume_total) if resume_total is not None else 0
- except Exception:
- pos, tot = 0, 0
- if pos > 0 and tot > 0:
- set_resume = getattr(tag, "setResumePoint", None)
- if callable(set_resume):
- try:
- set_resume(pos, tot)
- except Exception:
- try:
- set_resume(pos) # type: ignore[misc]
- except Exception:
- pass
-
- if cast:
- set_cast = getattr(tag, "setCast", None)
- actor_cls = getattr(xbmc, "Actor", None)
- if callable(set_cast) and actor_cls is not None:
- actors = []
- for index, member in enumerate(cast[:30]):
- try:
- actors.append(actor_cls(member.name, member.role, index, member.thumb))
- except Exception:
- try:
- actors.append(actor_cls(member.name, member.role))
- except Exception:
- continue
- try:
- set_cast(actors)
- except Exception:
- pass
- elif callable(set_cast):
- cast_dicts = [
- {"name": m.name, "role": m.role, "thumbnail": m.thumb}
- for m in cast[:30]
- if m.name
- ]
- try:
- set_cast(cast_dicts)
- except Exception:
- pass
-
- return
- except Exception:
- # Fallback below
- pass
-
- # Deprecated fallback for older Kodi.
- try:
- item.setInfo("video", info_labels) # type: ignore[arg-type]
- except Exception:
- pass
- if cast:
- set_cast = getattr(item, "setCast", None)
- if callable(set_cast):
- try:
- set_cast([m.name for m in cast[:30] if m.name])
- except Exception:
- pass
-
-
-def _get_log_path(filename: str) -> str:
- if xbmcaddon and xbmcvfs:
- addon = xbmcaddon.Addon()
- profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
- log_dir = os.path.join(profile, "logs")
- if not xbmcvfs.exists(log_dir):
- xbmcvfs.mkdirs(log_dir)
- return os.path.join(log_dir, filename)
- return os.path.join(os.path.dirname(__file__), filename)
-
-
-def _tmdb_file_log(message: str) -> None:
- global _TMDB_LOG_PATH
- if _TMDB_LOG_PATH is None:
- _TMDB_LOG_PATH = _get_log_path("tmdb.log")
- timestamp = datetime.utcnow().isoformat(timespec="seconds") + "Z"
- line = f"{timestamp}\t{message}\n"
- try:
- with open(_TMDB_LOG_PATH, "a", encoding="utf-8") as handle:
- handle.write(line)
- except Exception:
- if xbmcvfs is None:
- return
- try:
- handle = xbmcvfs.File(_TMDB_LOG_PATH, "a")
- handle.write(line)
- handle.close()
- except Exception:
- return
-
-
-def _tmdb_labels_and_art(title: str) -> tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]:
- title_key = (title or "").strip().casefold()
- language = _get_setting_string("tmdb_language").strip() or "de-DE"
- show_plot = _get_setting_bool("tmdb_show_plot", default=True)
- show_art = _get_setting_bool("tmdb_show_art", default=True)
- show_fanart = _get_setting_bool("tmdb_show_fanart", default=True)
- show_rating = _get_setting_bool("tmdb_show_rating", default=True)
- show_votes = _get_setting_bool("tmdb_show_votes", default=False)
- show_cast = _get_setting_bool("tmdb_show_cast", default=False)
- flags = f"p{int(show_plot)}a{int(show_art)}f{int(show_fanart)}r{int(show_rating)}v{int(show_votes)}c{int(show_cast)}"
- cache_key = f"{language}|{flags}|{title_key}"
- cached = _TMDB_CACHE.get(cache_key)
- if cached is not None:
- info, art = cached
- # Cast wird nicht in _TMDB_CACHE gehalten (weil es ListItem.setCast betrifft), daher separat cachen:
- cast_cached = _TMDB_CAST_CACHE.get(cache_key, [])
- return info, art, list(cast_cached)
-
- info_labels: dict[str, str] = {"title": title}
- art: dict[str, str] = {}
- cast: list[TmdbCastMember] = []
- query = (title or "").strip()
- api_key = _get_setting_string("tmdb_api_key").strip()
- log_requests = _get_setting_bool("tmdb_log_requests", default=False)
- log_responses = _get_setting_bool("tmdb_log_responses", default=False)
- if api_key:
- try:
- log_fn = _tmdb_file_log if (log_requests or log_responses) else None
- # Einige Plugins liefern Titel wie "… – Der Film". Für TMDB ist oft der Basistitel besser.
- candidates: list[str] = []
- if query:
- candidates.append(query)
- simplified = re.sub(r"\s*[-–]\s*der\s+film\s*$", "", query, flags=re.IGNORECASE).strip()
- if simplified and simplified not in candidates:
- candidates.append(simplified)
-
- meta = None
- is_tv = False
- for candidate in candidates:
- meta = lookup_tv_show(
- title=candidate,
- api_key=api_key,
- language=language,
- log=log_fn,
- log_responses=log_responses,
- include_cast=show_cast,
- )
- if meta:
- is_tv = True
- break
- if not meta:
- for candidate in candidates:
- movie = lookup_movie(
- title=candidate,
- api_key=api_key,
- language=language,
- log=log_fn,
- log_responses=log_responses,
- include_cast=show_cast,
- )
- if movie:
- meta = movie
- break
- except Exception as exc:
- try:
- _tmdb_file_log(f"TMDB ERROR lookup_failed title={title!r} error={exc!r}")
- except Exception:
- pass
- _log(f"TMDB Meta fehlgeschlagen: {exc}", xbmc.LOGDEBUG)
- meta = None
- if meta:
- # Nur TV-IDs cachen (für Staffel-/Episoden-Lookups); Movie-IDs würden dort fehlschlagen.
- if is_tv:
- _TMDB_ID_CACHE[title_key] = int(getattr(meta, "tmdb_id", 0) or 0)
- info_labels.setdefault("mediatype", "tvshow")
- else:
- info_labels.setdefault("mediatype", "movie")
- if show_plot and getattr(meta, "plot", ""):
- info_labels["plot"] = getattr(meta, "plot", "")
- runtime_minutes = int(getattr(meta, "runtime_minutes", 0) or 0)
- if runtime_minutes > 0 and not is_tv:
- info_labels["duration"] = str(runtime_minutes * 60)
- rating = getattr(meta, "rating", 0.0) or 0.0
- votes = getattr(meta, "votes", 0) or 0
- if show_rating and rating:
- # Kodi akzeptiert je nach Version float oder string; wir bleiben bei strings wie im restlichen Code.
- info_labels["rating"] = str(rating)
- if show_votes and votes:
- info_labels["votes"] = str(votes)
- if show_art and getattr(meta, "poster", ""):
- poster = getattr(meta, "poster", "")
- art.update({"thumb": poster, "poster": poster, "icon": poster})
- if show_fanart and getattr(meta, "fanart", ""):
- fanart = getattr(meta, "fanart", "")
- if fanart:
- art.update({"fanart": fanart, "landscape": fanart})
- if show_cast:
- cast = list(getattr(meta, "cast", []) or [])
- elif log_requests or log_responses:
- _tmdb_file_log(f"TMDB MISS title={title!r}")
-
- _TMDB_CACHE[cache_key] = (info_labels, art)
- _TMDB_CAST_CACHE[cache_key] = list(cast)
- return info_labels, art, list(cast)
-
-
-async def _tmdb_labels_and_art_bulk_async(
- titles: list[str],
-) -> dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]]:
- titles = [str(t).strip() for t in (titles or []) if t and str(t).strip()]
- if not titles:
- return {}
-
- unique_titles: list[str] = list(dict.fromkeys(titles))
- limit = _tmdb_prefetch_concurrency()
- semaphore = asyncio.Semaphore(limit)
-
- async def fetch_one(title: str):
- async with semaphore:
- return title, await asyncio.to_thread(_tmdb_labels_and_art, title)
-
- tasks = [fetch_one(title) for title in unique_titles]
- results = await asyncio.gather(*tasks, return_exceptions=True)
- mapped: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
- for entry in results:
- if isinstance(entry, Exception):
- continue
- try:
- title, payload = entry
- except Exception:
- continue
- if isinstance(title, str) and isinstance(payload, tuple) and len(payload) == 3:
- mapped[title] = payload # type: ignore[assignment]
- return mapped
-
-
-def _tmdb_labels_and_art_bulk(
- titles: list[str],
-) -> dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]]:
- return _run_async(_tmdb_labels_and_art_bulk_async(titles))
-
-
-def _tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label: str) -> tuple[dict[str, str], dict[str, str]]:
- title_key = (title or "").strip().casefold()
- tmdb_id = _TMDB_ID_CACHE.get(title_key)
- if not tmdb_id:
- _tmdb_labels_and_art(title)
- tmdb_id = _TMDB_ID_CACHE.get(title_key)
- if not tmdb_id:
- return {"title": episode_label}, {}
-
- season_number = _extract_first_int(season_label)
- episode_number = _extract_first_int(episode_label)
- if season_number is None or episode_number is None:
- return {"title": episode_label}, {}
-
- language = _get_setting_string("tmdb_language").strip() or "de-DE"
- show_plot = _get_setting_bool("tmdb_show_plot", default=True)
- show_art = _get_setting_bool("tmdb_show_art", default=True)
- flags = f"p{int(show_plot)}a{int(show_art)}"
- season_key = (tmdb_id, season_number, language, flags)
- cached_season = _TMDB_SEASON_CACHE.get(season_key)
- if cached_season is None:
- api_key = _get_setting_string("tmdb_api_key").strip()
- if not api_key:
- return {"title": episode_label}, {}
- log_requests = _get_setting_bool("tmdb_log_requests", default=False)
- log_responses = _get_setting_bool("tmdb_log_responses", default=False)
- log_fn = _tmdb_file_log if (log_requests or log_responses) else None
- try:
- season_meta = lookup_tv_season(
- tmdb_id=tmdb_id,
- season_number=season_number,
- api_key=api_key,
- language=language,
- log=log_fn,
- log_responses=log_responses,
- )
- except Exception as exc:
- if log_fn:
- log_fn(f"TMDB ERROR season_lookup_failed tmdb_id={tmdb_id} season={season_number} error={exc!r}")
- season_meta = None
- mapped: dict[int, tuple[dict[str, str], dict[str, str]]] = {}
- if season_meta:
- for ep_no, ep in season_meta.items():
- info: dict[str, str] = {"title": f"Episode {ep_no}"}
- if show_plot and ep.plot:
- info["plot"] = ep.plot
- if getattr(ep, "runtime_minutes", 0):
- info["duration"] = str(int(getattr(ep, "runtime_minutes", 0)) * 60)
- art: dict[str, str] = {}
- if show_art and ep.thumb:
- art = {"thumb": ep.thumb}
- mapped[ep_no] = (info, art)
- _TMDB_SEASON_CACHE[season_key] = mapped
- cached_season = mapped
-
- return cached_season.get(episode_number, ({"title": episode_label}, {}))
-
-
-def _tmdb_episode_cast(*, title: str, season_label: str, episode_label: str) -> list[TmdbCastMember]:
- show_episode_cast = _get_setting_bool("tmdb_show_episode_cast", default=False)
- if not show_episode_cast:
- return []
-
- title_key = (title or "").strip().casefold()
- tmdb_id = _TMDB_ID_CACHE.get(title_key)
- if not tmdb_id:
- _tmdb_labels_and_art(title)
- tmdb_id = _TMDB_ID_CACHE.get(title_key)
- if not tmdb_id:
- return []
-
- season_number = _extract_first_int(season_label)
- episode_number = _extract_first_int(episode_label)
- if season_number is None or episode_number is None:
- return []
-
- language = _get_setting_string("tmdb_language").strip() or "de-DE"
- cache_key = (tmdb_id, season_number, episode_number, language)
- cached = _TMDB_EPISODE_CAST_CACHE.get(cache_key)
- if cached is not None:
- return list(cached)
-
- api_key = _get_setting_string("tmdb_api_key").strip()
- if not api_key:
- _TMDB_EPISODE_CAST_CACHE[cache_key] = []
- return []
-
- log_requests = _get_setting_bool("tmdb_log_requests", default=False)
- log_responses = _get_setting_bool("tmdb_log_responses", default=False)
- log_fn = _tmdb_file_log if (log_requests or log_responses) else None
- try:
- cast = fetch_tv_episode_credits(
- tmdb_id=tmdb_id,
- season_number=season_number,
- episode_number=episode_number,
- api_key=api_key,
- language=language,
- log=log_fn,
- log_responses=log_responses,
- )
- except Exception as exc:
- if log_fn:
- log_fn(
- f"TMDB ERROR episode_credits_failed tmdb_id={tmdb_id} season={season_number} episode={episode_number} error={exc!r}"
- )
- cast = []
- _TMDB_EPISODE_CAST_CACHE[cache_key] = list(cast)
- return list(cast)
-
-
-def _add_directory_item(
- handle: int,
- label: str,
- action: str,
- params: dict[str, str] | None = None,
- *,
- is_folder: bool = True,
- info_labels: dict[str, str] | None = None,
- art: dict[str, str] | None = None,
- cast: list[TmdbCastMember] | None = None,
-) -> None:
- """Fuegt einen Eintrag (Folder oder Playable) in die Kodi-Liste ein."""
- query: dict[str, str] = {"action": action}
- if params:
- query.update(params)
- url = f"{sys.argv[0]}?{urlencode(query)}"
- item = xbmcgui.ListItem(label=label)
- if not is_folder:
- try:
- item.setProperty("IsPlayable", "true")
- except Exception:
- pass
- _apply_video_info(item, info_labels, cast)
- if art:
- setter = getattr(item, "setArt", None)
- if callable(setter):
- try:
- setter(art)
- except Exception:
- pass
- xbmcplugin.addDirectoryItem(handle=handle, url=url, listitem=item, isFolder=is_folder)
-
-
-def _show_root_menu() -> None:
- handle = _get_handle()
- _log("Root-Menue wird angezeigt.")
- _add_directory_item(handle, "Globale Suche", "search")
-
- plugins = _discover_plugins()
- for plugin_name in sorted(plugins.keys(), key=lambda value: value.casefold()):
- display = f"{plugin_name}"
- _add_directory_item(handle, display, "plugin_menu", {"plugin": plugin_name}, is_folder=True)
-
- _add_directory_item(handle, "Einstellungen", "settings")
- xbmcplugin.endOfDirectory(handle)
-
-
-def _show_plugin_menu(plugin_name: str) -> None:
- handle = _get_handle()
- plugin_name = (plugin_name or "").strip()
- plugin = _discover_plugins().get(plugin_name)
- if not plugin:
- xbmcgui.Dialog().notification("Plugin", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- xbmcplugin.setPluginCategory(handle, plugin_name)
-
- _add_directory_item(handle, "Suche", "plugin_search", {"plugin": plugin_name}, is_folder=True)
-
- if _plugin_has_capability(plugin, "new_titles"):
- _add_directory_item(handle, "Neue Titel", "new_titles", {"plugin": plugin_name, "page": "1"}, is_folder=True)
-
- if _plugin_has_capability(plugin, "latest_episodes"):
- _add_directory_item(handle, "Neueste Folgen", "latest_episodes", {"plugin": plugin_name, "page": "1"}, is_folder=True)
-
- if _plugin_has_capability(plugin, "genres"):
- _add_directory_item(handle, "Genres", "genres", {"plugin": plugin_name}, is_folder=True)
-
- if _plugin_has_capability(plugin, "popular_series"):
- _add_directory_item(handle, "Meist gesehen", "popular", {"plugin": plugin_name, "page": "1"}, is_folder=True)
-
- xbmcplugin.endOfDirectory(handle)
-
-
-def _show_plugin_search(plugin_name: str) -> None:
- plugin_name = (plugin_name or "").strip()
- plugin = _discover_plugins().get(plugin_name)
- if not plugin:
- xbmcgui.Dialog().notification("Suche", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- _show_root_menu()
- return
-
- _log(f"Plugin-Suche gestartet: {plugin_name}")
- dialog = xbmcgui.Dialog()
- query = dialog.input(f"{plugin_name}: Titel eingeben", type=xbmcgui.INPUT_ALPHANUM).strip()
- if not query:
- _log("Plugin-Suche abgebrochen (leere Eingabe).", xbmc.LOGDEBUG)
- _show_plugin_menu(plugin_name)
- return
- _log(f"Plugin-Suchbegriff ({plugin_name}): {query}", xbmc.LOGDEBUG)
- _show_plugin_search_results(plugin_name, query)
-
-
-def _show_plugin_search_results(plugin_name: str, query: str) -> None:
- handle = _get_handle()
- plugin_name = (plugin_name or "").strip()
- query = (query or "").strip()
- plugin = _discover_plugins().get(plugin_name)
- if not plugin:
- xbmcgui.Dialog().notification("Suche", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- xbmcplugin.setPluginCategory(handle, f"{plugin_name}: {query}")
- _set_content(handle, "movies" if plugin_name.casefold() == "einschalten" else "tvshows")
- _log(f"Suche nach Titeln (Plugin={plugin_name}): {query}")
-
- try:
- results = _run_async(plugin.search_titles(query))
- except Exception as exc:
- _log(f"Suche fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
- xbmcgui.Dialog().notification("Suche", "Suche fehlgeschlagen.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- results = [str(t).strip() for t in (results or []) if t and str(t).strip()]
- results.sort(key=lambda value: value.casefold())
- tmdb_prefetched: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
- if results:
- with _busy_dialog():
- tmdb_prefetched = _tmdb_labels_and_art_bulk(list(results))
- for title in results:
- info_labels, art, cast = tmdb_prefetched.get(title, _tmdb_labels_and_art(title))
- info_labels = dict(info_labels or {})
- info_labels.setdefault("mediatype", "tvshow")
- if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
- info_labels.setdefault("tvshowtitle", title)
- playstate = _title_playstate(plugin_name, title)
- merged_info = _apply_playstate_to_info(dict(info_labels), playstate)
- display_label = _label_with_duration(title, info_labels)
- display_label = _label_with_playstate(display_label, playstate)
- direct_play = bool(plugin_name.casefold() == "einschalten" and _get_setting_bool("einschalten_enable_playback", default=False))
- _add_directory_item(
- handle,
- display_label,
- "play_movie" if direct_play else "seasons",
- {"plugin": plugin_name, "title": title},
- is_folder=not direct_play,
- info_labels=merged_info,
- art=art,
- cast=cast,
- )
- xbmcplugin.endOfDirectory(handle)
-
-
-def _import_plugin_module(path: Path) -> ModuleType:
- spec = importlib.util.spec_from_file_location(path.stem, path)
- if spec is None or spec.loader is None:
- raise ImportError(f"Modul-Spezifikation fuer {path.name} fehlt.")
- module = importlib.util.module_from_spec(spec)
- sys.modules[spec.name] = module
- try:
- spec.loader.exec_module(module)
- except Exception:
- sys.modules.pop(spec.name, None)
- raise
- return module
-
-
-def _discover_plugins() -> dict[str, BasisPlugin]:
- """Laedt alle Plugins aus `plugins/*.py` und cached Instanzen im RAM."""
- global _PLUGIN_CACHE
- if _PLUGIN_CACHE is not None:
- return _PLUGIN_CACHE
- # Plugins werden dynamisch aus `plugins/*.py` geladen, damit Integrationen getrennt
- # entwickelt und bei Fehlern isoliert deaktiviert werden koennen.
- plugins: dict[str, BasisPlugin] = {}
- if not PLUGIN_DIR.exists():
- _PLUGIN_CACHE = plugins
- return plugins
- for file_path in sorted(PLUGIN_DIR.glob("*.py")):
- if file_path.name.startswith("_"):
- continue
- try:
- module = _import_plugin_module(file_path)
- except Exception as exc:
- xbmc.log(f"Plugin-Datei {file_path.name} konnte nicht geladen werden: {exc}", xbmc.LOGWARNING)
- continue
- plugin_classes = [
- obj
- for obj in module.__dict__.values()
- if inspect.isclass(obj) and issubclass(obj, BasisPlugin) and obj is not BasisPlugin
- ]
- for cls in plugin_classes:
- try:
- instance = cls()
- except Exception as exc:
- xbmc.log(f"Plugin {cls.__name__} konnte nicht geladen werden: {exc}", xbmc.LOGWARNING)
- continue
- if getattr(instance, "is_available", True) is False:
- reason = getattr(instance, "unavailable_reason", "Nicht verfuegbar.")
- xbmc.log(f"Plugin {cls.__name__} deaktiviert: {reason}", xbmc.LOGWARNING)
- continue
- plugins[instance.name] = instance
- _PLUGIN_CACHE = plugins
- return plugins
-
-
-def _run_async(coro):
- """Fuehrt eine Coroutine aus, auch wenn Kodi bereits einen Event-Loop hat."""
- try:
- loop = asyncio.get_event_loop()
- except RuntimeError:
- loop = None
- if loop and loop.is_running():
- temp_loop = asyncio.new_event_loop()
- try:
- return temp_loop.run_until_complete(coro)
- finally:
- temp_loop.close()
- return asyncio.run(coro)
-
-
-def _show_search() -> None:
- _log("Suche gestartet.")
- dialog = xbmcgui.Dialog()
- query = dialog.input("Serientitel eingeben", type=xbmcgui.INPUT_ALPHANUM).strip()
- if not query:
- _log("Suche abgebrochen (leere Eingabe).", xbmc.LOGDEBUG)
- _show_root_menu()
- return
- _log(f"Suchbegriff: {query}", xbmc.LOGDEBUG)
- _show_search_results(query)
-
-
-def _show_search_results(query: str) -> None:
- handle = _get_handle()
- _log(f"Suche nach Titeln: {query}")
- _set_content(handle, "tvshows")
- plugins = _discover_plugins()
- if not plugins:
- xbmcgui.Dialog().notification("Suche", "Keine Plugins gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
- for plugin_name, plugin in plugins.items():
- try:
- results = _run_async(plugin.search_titles(query))
- except Exception as exc:
- _log(f"Suche fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
- continue
- _log(f"Treffer ({plugin_name}): {len(results)}", xbmc.LOGDEBUG)
- tmdb_prefetched: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
- if results:
- with _busy_dialog():
- tmdb_prefetched = _tmdb_labels_and_art_bulk(list(results))
- for title in results:
- info_labels, art, cast = tmdb_prefetched.get(title, _tmdb_labels_and_art(title))
- info_labels = dict(info_labels or {})
- info_labels.setdefault("mediatype", "tvshow")
- if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
- info_labels.setdefault("tvshowtitle", title)
- playstate = _title_playstate(plugin_name, title)
- merged_info = _apply_playstate_to_info(dict(info_labels), playstate)
- label = _label_with_duration(title, info_labels)
- label = _label_with_playstate(label, playstate)
- label = f"{label} [{plugin_name}]"
- direct_play = bool(
- plugin_name.casefold() == "einschalten" and _get_setting_bool("einschalten_enable_playback", default=False)
- )
- _add_directory_item(
- handle,
- label,
- "play_movie" if direct_play else "seasons",
- {"plugin": plugin_name, "title": title},
- is_folder=not direct_play,
- info_labels=merged_info,
- art=art,
- cast=cast,
- )
- xbmcplugin.endOfDirectory(handle)
-
-
-def _show_seasons(plugin_name: str, title: str) -> None:
- handle = _get_handle()
- _log(f"Staffeln laden: {plugin_name} / {title}")
- plugin = _discover_plugins().get(plugin_name)
- if plugin is None:
- xbmcgui.Dialog().notification("Staffeln", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- # Einschalten liefert Filme. Für Playback soll nach dem Öffnen des Titels direkt ein
- # einzelnes abspielbares Item angezeigt werden: -> ( abspielbar).
- # Wichtig: ohne zusätzliche Netzwerkanfragen (sonst bleibt Kodi ggf. im Busy-Spinner hängen).
- if (plugin_name or "").casefold() == "einschalten" and _get_setting_bool("einschalten_enable_playback", default=False):
- xbmcplugin.setPluginCategory(handle, title)
- _set_content(handle, "movies")
- playstate = _title_playstate(plugin_name, title)
- info_labels: dict[str, object] = {"title": title, "mediatype": "movie"}
- info_labels = _apply_playstate_to_info(info_labels, playstate)
- display_label = _label_with_playstate(title, playstate)
- _add_directory_item(
- handle,
- display_label,
- "play_movie",
- {"plugin": plugin_name, "title": title},
- is_folder=False,
- info_labels=info_labels,
- )
- xbmcplugin.endOfDirectory(handle)
- return
-
- # Optional: Plugins können schnell (ohne Detail-Request) sagen, ob ein Titel ein Film ist.
- # Dann zeigen wir direkt ein einzelnes abspielbares Item: -> ().
- is_movie = getattr(plugin, "is_movie", None)
- if callable(is_movie):
- try:
- if bool(is_movie(title)):
- xbmcplugin.setPluginCategory(handle, title)
- _set_content(handle, "movies")
- playstate = _title_playstate(plugin_name, title)
- info_labels: dict[str, object] = {"title": title, "mediatype": "movie"}
- info_labels = _apply_playstate_to_info(info_labels, playstate)
- display_label = _label_with_playstate(title, playstate)
- _add_directory_item(
- handle,
- display_label,
- "play_movie",
- {"plugin": plugin_name, "title": title},
- is_folder=False,
- info_labels=info_labels,
- )
- xbmcplugin.endOfDirectory(handle)
- return
- except Exception:
- pass
-
- title_info_labels: dict[str, str] | None = None
- title_art: dict[str, str] | None = None
- title_cast: list[TmdbCastMember] | None = None
- meta_getter = getattr(plugin, "metadata_for", None)
- if callable(meta_getter):
- try:
- with _busy_dialog():
- meta_labels, meta_art, meta_cast = meta_getter(title)
- if isinstance(meta_labels, dict):
- title_info_labels = {str(k): str(v) for k, v in meta_labels.items() if v}
- if isinstance(meta_art, dict):
- title_art = {str(k): str(v) for k, v in meta_art.items() if v}
- if isinstance(meta_cast, list):
- # type: ignore[assignment] - plugins may return cast in their own shape; best-effort only
- title_cast = meta_cast # noqa: PGH003
- except Exception:
- pass
-
- try:
- seasons = plugin.seasons_for(title)
- except Exception as exc:
- _log(f"Staffeln laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
- xbmcgui.Dialog().notification("Staffeln", "Konnte Staffeln nicht laden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- count = len(seasons)
- suffix = "Staffel" if count == 1 else "Staffeln"
- xbmcplugin.setPluginCategory(handle, f"{title} ({count} {suffix})")
- _set_content(handle, "seasons")
- # Staffel-Metadaten (Plot/Poster) optional via TMDB.
- _tmdb_labels_and_art(title)
- api_key = _get_setting_string("tmdb_api_key").strip()
- language = _get_setting_string("tmdb_language").strip() or "de-DE"
- show_plot = _get_setting_bool("tmdb_show_plot", default=True)
- show_art = _get_setting_bool("tmdb_show_art", default=True)
- flags = f"p{int(show_plot)}a{int(show_art)}"
- log_requests = _get_setting_bool("tmdb_log_requests", default=False)
- log_responses = _get_setting_bool("tmdb_log_responses", default=False)
- log_fn = _tmdb_file_log if (log_requests or log_responses) else None
- for season in seasons:
- info_labels: dict[str, str] | None = None
- art: dict[str, str] | None = None
- season_number = _extract_first_int(season)
- if api_key and season_number is not None:
- cache_key = (_TMDB_ID_CACHE.get((title or "").strip().casefold(), 0), season_number, language, flags)
- cached = _TMDB_SEASON_SUMMARY_CACHE.get(cache_key)
- if cached is None and cache_key[0]:
- try:
- meta = lookup_tv_season_summary(
- tmdb_id=cache_key[0],
- season_number=season_number,
- api_key=api_key,
- language=language,
- log=log_fn,
- log_responses=log_responses,
- )
- except Exception as exc:
- if log_fn:
- log_fn(f"TMDB ERROR season_summary_failed tmdb_id={cache_key[0]} season={season_number} error={exc!r}")
- meta = None
- labels = {"title": season}
- art_map: dict[str, str] = {}
- if meta:
- if show_plot and meta.plot:
- labels["plot"] = meta.plot
- if show_art and meta.poster:
- art_map = {"thumb": meta.poster, "poster": meta.poster}
- cached = (labels, art_map)
- _TMDB_SEASON_SUMMARY_CACHE[cache_key] = cached
- if cached is not None:
- info_labels, art = cached
- merged_labels = dict(info_labels or {})
- if title_info_labels:
- merged_labels = dict(title_info_labels)
- merged_labels.update(dict(info_labels or {}))
- season_state = _season_playstate(plugin_name, title, season)
- merged_labels = _apply_playstate_to_info(dict(merged_labels), season_state)
- merged_art: dict[str, str] | None = art
- if title_art:
- merged_art = dict(title_art)
- if isinstance(art, dict):
- merged_art.update({k: str(v) for k, v in art.items() if v})
-
- _add_directory_item(
- handle,
- _label_with_playstate(season, season_state),
- "episodes",
- {"plugin": plugin_name, "title": title, "season": season},
- is_folder=True,
- info_labels=merged_labels or None,
- art=merged_art,
- cast=title_cast,
- )
- xbmcplugin.endOfDirectory(handle)
-
-
-def _show_episodes(plugin_name: str, title: str, season: str) -> None:
- handle = _get_handle()
- _log(f"Episoden laden: {plugin_name} / {title} / {season}")
- plugin = _discover_plugins().get(plugin_name)
- if plugin is None:
- xbmcgui.Dialog().notification("Episoden", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
- season_number = _extract_first_int(season)
- if season_number is not None:
- xbmcplugin.setPluginCategory(handle, f"{title} - Staffel {season_number}")
- else:
- xbmcplugin.setPluginCategory(handle, f"{title} - {season}")
- _set_content(handle, "episodes")
-
- episodes = list(plugin.episodes_for(title, season))
- if episodes:
- show_info, show_art, show_cast = _tmdb_labels_and_art(title)
- show_fanart = (show_art or {}).get("fanart") if isinstance(show_art, dict) else ""
- show_poster = (show_art or {}).get("poster") if isinstance(show_art, dict) else ""
- with _busy_dialog():
- for episode in episodes:
- info_labels, art = _tmdb_episode_labels_and_art(title=title, season_label=season, episode_label=episode)
- episode_cast = _tmdb_episode_cast(title=title, season_label=season, episode_label=episode)
- merged_info = dict(show_info or {})
- merged_info.update(dict(info_labels or {}))
- merged_art: dict[str, str] = {}
- if isinstance(show_art, dict):
- merged_art.update({k: str(v) for k, v in show_art.items() if v})
- if isinstance(art, dict):
- merged_art.update({k: str(v) for k, v in art.items() if v})
-
- # Kodi Info-Dialog für Episoden hängt oft an diesen Feldern.
- season_number = _extract_first_int(season) or 0
- episode_number = _extract_first_int(episode) or 0
- merged_info.setdefault("mediatype", "episode")
- merged_info.setdefault("tvshowtitle", title)
- if season_number:
- merged_info.setdefault("season", str(season_number))
- if episode_number:
- merged_info.setdefault("episode", str(episode_number))
-
- # Episode-Items ohne eigenes Artwork: Fanart/Poster vom Titel durchreichen.
- if show_fanart:
- merged_art.setdefault("fanart", show_fanart)
- merged_art.setdefault("landscape", show_fanart)
- if show_poster:
- merged_art.setdefault("poster", show_poster)
-
- key = _playstate_key(plugin_name=plugin_name, title=title, season=season, episode=episode)
- merged_info = _apply_playstate_to_info(merged_info, _get_playstate(key))
-
- display_label = episode
- _add_directory_item(
- handle,
- display_label,
- "play_episode",
- {"plugin": plugin_name, "title": title, "season": season, "episode": episode},
- is_folder=False,
- info_labels=merged_info,
- art=merged_art,
- cast=episode_cast or show_cast,
- )
- xbmcplugin.endOfDirectory(handle)
-
-
-def _show_genre_sources() -> None:
- handle = _get_handle()
- _log("Genre-Quellen laden.")
- plugins = _discover_plugins()
- sources: list[tuple[str, BasisPlugin]] = []
- for plugin_name, plugin in plugins.items():
- if plugin.__class__.genres is BasisPlugin.genres:
- continue
- if plugin.__class__.titles_for_genre is BasisPlugin.titles_for_genre:
- continue
- sources.append((plugin_name, plugin))
-
- if not sources:
- xbmcgui.Dialog().notification("Genres", "Keine Genre-Quellen gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- for plugin_name, plugin in sources:
- _add_directory_item(
- handle,
- f"Genres [{plugin_name}]",
- "genres",
- {"plugin": plugin_name},
- is_folder=True,
- )
- xbmcplugin.endOfDirectory(handle)
-
-
-def _show_genres(plugin_name: str) -> None:
- handle = _get_handle()
- _log(f"Genres laden: {plugin_name}")
- plugin = _discover_plugins().get(plugin_name)
- if plugin is None:
- xbmcgui.Dialog().notification("Genres", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
- try:
- genres = plugin.genres()
- except Exception as exc:
- _log(f"Genres konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
- xbmcgui.Dialog().notification("Genres", "Genres konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
- for genre in genres:
- # Wenn Plugin Paging unterstützt, direkt paginierte Titelliste öffnen.
- paging_getter = getattr(plugin, "titles_for_genre_page", None)
- if callable(paging_getter):
- _add_directory_item(
- handle,
- genre,
- "genre_titles_page",
- {"plugin": plugin_name, "genre": genre, "page": "1"},
- is_folder=True,
- )
- continue
- _add_directory_item(
- handle,
- genre,
- "genre_series",
- {"plugin": plugin_name, "genre": genre},
- is_folder=True,
- )
- xbmcplugin.endOfDirectory(handle)
-
-
-def _show_genre_titles_page(plugin_name: str, genre: str, page: int = 1) -> None:
- handle = _get_handle()
- plugin = _discover_plugins().get(plugin_name)
- if plugin is None:
- xbmcgui.Dialog().notification("Genres", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- page = max(1, int(page or 1))
- paging_getter = getattr(plugin, "titles_for_genre_page", None)
- if not callable(paging_getter):
- xbmcgui.Dialog().notification("Genres", "Paging nicht verfügbar.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- total_pages = None
- count_getter = getattr(plugin, "genre_page_count", None)
- if callable(count_getter):
- try:
- total_pages = int(count_getter(genre) or 1)
- except Exception:
- total_pages = None
- if total_pages is not None:
- page = min(page, max(1, total_pages))
- xbmcplugin.setPluginCategory(handle, f"{genre} ({page}/{total_pages})")
- else:
- xbmcplugin.setPluginCategory(handle, f"{genre} ({page})")
- _set_content(handle, "movies" if (plugin_name or "").casefold() == "einschalten" else "tvshows")
-
- if page > 1:
- _add_directory_item(
- handle,
- "Vorherige Seite",
- "genre_titles_page",
- {"plugin": plugin_name, "genre": genre, "page": str(page - 1)},
- is_folder=True,
- )
-
- try:
- titles = list(paging_getter(genre, page) or [])
- except Exception as exc:
- _log(f"Genre-Seite konnte nicht geladen werden ({plugin_name}/{genre} p{page}): {exc}", xbmc.LOGWARNING)
- xbmcgui.Dialog().notification("Genres", "Seite konnte nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- titles = [str(t).strip() for t in titles if t and str(t).strip()]
- titles.sort(key=lambda value: value.casefold())
-
- show_tmdb = _get_setting_bool("tmdb_genre_metadata", default=False)
- if titles:
- if show_tmdb:
- with _busy_dialog():
- tmdb_prefetched = _tmdb_labels_and_art_bulk(titles)
- for title in titles:
- info_labels, art, cast = tmdb_prefetched.get(title, _tmdb_labels_and_art(title))
- info_labels = dict(info_labels or {})
- info_labels.setdefault("mediatype", "tvshow")
- if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
- info_labels.setdefault("tvshowtitle", title)
- playstate = _title_playstate(plugin_name, title)
- info_labels = _apply_playstate_to_info(dict(info_labels), playstate)
- display_label = _label_with_duration(title, info_labels)
- display_label = _label_with_playstate(display_label, playstate)
- direct_play = bool(
- plugin_name.casefold() == "einschalten"
- and _get_setting_bool("einschalten_enable_playback", default=False)
- )
- _add_directory_item(
- handle,
- display_label,
- "play_movie" if direct_play else "seasons",
- {"plugin": plugin_name, "title": title},
- is_folder=not direct_play,
- info_labels=info_labels,
- art=art,
- cast=cast,
- )
- else:
- for title in titles:
- playstate = _title_playstate(plugin_name, title)
- direct_play = bool(
- plugin_name.casefold() == "einschalten"
- and _get_setting_bool("einschalten_enable_playback", default=False)
- )
- _add_directory_item(
- handle,
- _label_with_playstate(title, playstate),
- "play_movie" if direct_play else "seasons",
- {"plugin": plugin_name, "title": title},
- is_folder=not direct_play,
- info_labels=_apply_playstate_to_info({"title": title}, playstate),
- )
-
- show_next = False
- if total_pages is not None:
- show_next = page < total_pages
- else:
- has_more_getter = getattr(plugin, "genre_has_more", None)
- if callable(has_more_getter):
- try:
- show_next = bool(has_more_getter(genre, page))
- except Exception:
- show_next = False
-
- if show_next:
- _add_directory_item(
- handle,
- "Nächste Seite",
- "genre_titles_page",
- {"plugin": plugin_name, "genre": genre, "page": str(page + 1)},
- is_folder=True,
- )
- xbmcplugin.endOfDirectory(handle)
-
-
-def _title_group_key(title: str) -> str:
- raw = (title or "").strip()
- if not raw:
- return "#"
- for char in raw:
- if char.isdigit():
- return "0-9"
- if char.isalpha():
- normalized = char.casefold()
- if normalized == "ä":
- normalized = "a"
- elif normalized == "ö":
- normalized = "o"
- elif normalized == "ü":
- normalized = "u"
- elif normalized == "ß":
- normalized = "s"
- return normalized.upper()
- return "#"
-
-
-def _genre_title_groups() -> list[tuple[str, str]]:
- return [
- ("A-E", "A-E"),
- ("F-J", "F-J"),
- ("K-O", "K-O"),
- ("P-T", "P-T"),
- ("U-Z", "U-Z"),
- ("0-9", "0-9"),
- ]
-
-
-def _group_matches(group_code: str, title: str) -> bool:
- key = _title_group_key(title)
- if group_code == "0-9":
- return key == "0-9"
- if key == "0-9" or key == "#":
- return False
- if group_code == "A-E":
- return "A" <= key <= "E"
- if group_code == "F-J":
- return "F" <= key <= "J"
- if group_code == "K-O":
- return "K" <= key <= "O"
- if group_code == "P-T":
- return "P" <= key <= "T"
- if group_code == "U-Z":
- return "U" <= key <= "Z"
- return False
-
-
-def _get_genre_titles(plugin_name: str, genre: str) -> list[str]:
- cache_key = (plugin_name, genre)
- cached = _GENRE_TITLES_CACHE.get(cache_key)
- if cached is not None:
- return list(cached)
- plugin = _discover_plugins().get(plugin_name)
- if plugin is None:
- return []
- titles = plugin.titles_for_genre(genre)
- titles = [str(t).strip() for t in titles if t and str(t).strip()]
- titles.sort(key=lambda value: value.casefold())
- _GENRE_TITLES_CACHE[cache_key] = list(titles)
- return list(titles)
-
-
-def _show_genre_series(plugin_name: str, genre: str) -> None:
- handle = _get_handle()
- xbmcplugin.setPluginCategory(handle, genre)
- for label, group_code in _genre_title_groups():
- _add_directory_item(
- handle,
- label,
- "genre_series_group",
- {"plugin": plugin_name, "genre": genre, "group": group_code},
- is_folder=True,
- )
- xbmcplugin.endOfDirectory(handle)
-
-
-def _parse_positive_int(value: str, *, default: int = 1) -> int:
- try:
- parsed = int(str(value or "").strip())
- except Exception:
- return default
- return parsed if parsed > 0 else default
-
-
-def _popular_genre_label(plugin: BasisPlugin) -> str | None:
- label = getattr(plugin, "POPULAR_GENRE_LABEL", None)
- if isinstance(label, str) and label.strip():
- return label.strip()
- return None
-
-
-def _plugin_has_capability(plugin: BasisPlugin, capability: str) -> bool:
- getter = getattr(plugin, "capabilities", None)
- if callable(getter):
- try:
- capabilities = getter()
- except Exception:
- capabilities = set()
- try:
- return capability in set(capabilities or [])
- except Exception:
- return False
- # Backwards compatibility: Popular via POPULAR_GENRE_LABEL constant.
- if capability == "popular_series":
- return _popular_genre_label(plugin) is not None
- return False
-
-
-def _plugins_with_popular() -> list[tuple[str, BasisPlugin, str]]:
- results: list[tuple[str, BasisPlugin, str]] = []
- for plugin_name, plugin in _discover_plugins().items():
- if not _plugin_has_capability(plugin, "popular_series"):
- continue
- label = _popular_genre_label(plugin) or ""
- results.append((plugin_name, plugin, label))
- return results
-
-
-def _show_popular(plugin_name: str | None = None, page: int = 1) -> None:
- handle = _get_handle()
- page_size = 10
- page = max(1, int(page or 1))
-
- if plugin_name:
- plugin = _discover_plugins().get(plugin_name)
- if plugin is None or not _plugin_has_capability(plugin, "popular_series"):
- xbmcgui.Dialog().notification("Beliebte Serien", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
- try:
- popular_getter = getattr(plugin, "popular_series", None)
- if callable(popular_getter):
- titles = list(popular_getter() or [])
- else:
- label = _popular_genre_label(plugin)
- if not label:
- titles = []
- else:
- titles = list(plugin.titles_for_genre(label) or [])
- except Exception as exc:
- _log(f"Beliebte Serien konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
- xbmcgui.Dialog().notification("Beliebte Serien", "Serien konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- titles = [str(t).strip() for t in titles if t and str(t).strip()]
- titles.sort(key=lambda value: value.casefold())
- total = len(titles)
- total_pages = max(1, (total + page_size - 1) // page_size)
- page = min(page, total_pages)
- xbmcplugin.setPluginCategory(handle, f"Beliebte Serien [{plugin_name}] ({page}/{total_pages})")
- _set_content(handle, "tvshows")
-
- if total_pages > 1 and page > 1:
- _add_directory_item(
- handle,
- "Vorherige Seite",
- "popular",
- {"plugin": plugin_name, "page": str(page - 1)},
- is_folder=True,
- )
-
- start = (page - 1) * page_size
- end = start + page_size
- page_items = titles[start:end]
-
- show_tmdb = _get_setting_bool("tmdb_genre_metadata", default=False)
- if page_items:
- if show_tmdb:
- with _busy_dialog():
- tmdb_prefetched = _tmdb_labels_and_art_bulk(page_items)
- for title in page_items:
- info_labels, art, cast = tmdb_prefetched.get(title, _tmdb_labels_and_art(title))
- info_labels = dict(info_labels or {})
- info_labels.setdefault("mediatype", "tvshow")
- if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
- info_labels.setdefault("tvshowtitle", title)
- playstate = _title_playstate(plugin_name, title)
- info_labels = _apply_playstate_to_info(dict(info_labels), playstate)
- display_label = _label_with_duration(title, info_labels)
- display_label = _label_with_playstate(display_label, playstate)
- _add_directory_item(
- handle,
- display_label,
- "seasons",
- {"plugin": plugin_name, "title": title},
- is_folder=True,
- info_labels=info_labels,
- art=art,
- cast=cast,
- )
- else:
- for title in page_items:
- playstate = _title_playstate(plugin_name, title)
- _add_directory_item(
- handle,
- _label_with_playstate(title, playstate),
- "seasons",
- {"plugin": plugin_name, "title": title},
- is_folder=True,
- info_labels=_apply_playstate_to_info({"title": title}, playstate),
- )
-
- if total_pages > 1 and page < total_pages:
- _add_directory_item(
- handle,
- "Nächste Seite",
- "popular",
- {"plugin": plugin_name, "page": str(page + 1)},
- is_folder=True,
- )
- xbmcplugin.endOfDirectory(handle)
- return
-
- sources = _plugins_with_popular()
- if not sources:
- xbmcgui.Dialog().notification("Beliebte Serien", "Keine Quellen gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- xbmcplugin.setPluginCategory(handle, "Beliebte Serien")
- for name, plugin, _label in sources:
- _add_directory_item(
- handle,
- f"Beliebte Serien [{plugin.name}]",
- "popular",
- {"plugin": name, "page": "1"},
- is_folder=True,
- )
- xbmcplugin.endOfDirectory(handle)
-
-
-def _show_new_titles(plugin_name: str, page: int = 1) -> None:
- handle = _get_handle()
- page_size = 10
- page = max(1, int(page or 1))
-
- plugin_name = (plugin_name or "").strip()
- plugin = _discover_plugins().get(plugin_name)
- if plugin is None or not _plugin_has_capability(plugin, "new_titles"):
- xbmcgui.Dialog().notification("Neue Titel", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- getter = getattr(plugin, "new_titles", None)
- if not callable(getter):
- xbmcgui.Dialog().notification("Neue Titel", "Nicht verfügbar.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- paging_getter = getattr(plugin, "new_titles_page", None)
- has_more_getter = getattr(plugin, "new_titles_has_more", None)
-
- if callable(paging_getter):
- xbmcplugin.setPluginCategory(handle, f"Neue Titel [{plugin_name}] ({page})")
- _set_content(handle, "movies" if plugin_name.casefold() == "einschalten" else "tvshows")
- if page > 1:
- _add_directory_item(
- handle,
- "Vorherige Seite",
- "new_titles",
- {"plugin": plugin_name, "page": str(page - 1)},
- is_folder=True,
- )
- try:
- page_items = list(paging_getter(page) or [])
- except Exception as exc:
- _log(f"Neue Titel konnten nicht geladen werden ({plugin_name} p{page}): {exc}", xbmc.LOGWARNING)
- xbmcgui.Dialog().notification("Neue Titel", "Titel konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
- page_items = [str(t).strip() for t in page_items if t and str(t).strip()]
- page_items.sort(key=lambda value: value.casefold())
- else:
- try:
- titles = list(getter() or [])
- except Exception as exc:
- _log(f"Neue Titel konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
- xbmcgui.Dialog().notification("Neue Titel", "Titel konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- titles = [str(t).strip() for t in titles if t and str(t).strip()]
- titles.sort(key=lambda value: value.casefold())
- total = len(titles)
- if total == 0:
- xbmcgui.Dialog().notification(
- "Neue Titel",
- "Keine Titel gefunden (Basis-URL/Index prüfen).",
- xbmcgui.NOTIFICATION_INFO,
- 4000,
- )
- total_pages = max(1, (total + page_size - 1) // page_size)
- page = min(page, total_pages)
- xbmcplugin.setPluginCategory(handle, f"Neue Titel [{plugin_name}] ({page}/{total_pages})")
- _set_content(handle, "movies" if plugin_name.casefold() == "einschalten" else "tvshows")
-
- if total_pages > 1 and page > 1:
- _add_directory_item(
- handle,
- "Vorherige Seite",
- "new_titles",
- {"plugin": plugin_name, "page": str(page - 1)},
- is_folder=True,
- )
-
- start = (page - 1) * page_size
- end = start + page_size
- page_items = titles[start:end]
- show_tmdb = _get_setting_bool("tmdb_genre_metadata", default=False)
- if page_items:
- if show_tmdb:
- with _busy_dialog():
- tmdb_prefetched = _tmdb_labels_and_art_bulk(page_items)
- for title in page_items:
- info_labels, art, cast = tmdb_prefetched.get(title, _tmdb_labels_and_art(title))
- info_labels = dict(info_labels or {})
- info_labels.setdefault("mediatype", "movie")
- playstate = _title_playstate(plugin_name, title)
- info_labels = _apply_playstate_to_info(dict(info_labels), playstate)
- display_label = _label_with_duration(title, info_labels)
- display_label = _label_with_playstate(display_label, playstate)
- direct_play = bool(
- plugin_name.casefold() == "einschalten"
- and _get_setting_bool("einschalten_enable_playback", default=False)
- )
- _add_directory_item(
- handle,
- display_label,
- "play_movie" if direct_play else "seasons",
- {"plugin": plugin_name, "title": title},
- is_folder=not direct_play,
- info_labels=info_labels,
- art=art,
- cast=cast,
- )
- else:
- for title in page_items:
- playstate = _title_playstate(plugin_name, title)
- direct_play = bool(
- plugin_name.casefold() == "einschalten"
- and _get_setting_bool("einschalten_enable_playback", default=False)
- )
- _add_directory_item(
- handle,
- _label_with_playstate(title, playstate),
- "play_movie" if direct_play else "seasons",
- {"plugin": plugin_name, "title": title},
- is_folder=not direct_play,
- info_labels=_apply_playstate_to_info({"title": title}, playstate),
- )
-
- show_next = False
- if callable(paging_getter) and callable(has_more_getter):
- try:
- show_next = bool(has_more_getter(page))
- except Exception:
- show_next = False
- elif "total_pages" in locals():
- show_next = bool(total_pages > 1 and page < total_pages) # type: ignore[name-defined]
-
- if show_next:
- _add_directory_item(
- handle,
- "Nächste Seite",
- "new_titles",
- {"plugin": plugin_name, "page": str(page + 1)},
- is_folder=True,
- )
- xbmcplugin.endOfDirectory(handle)
-
-
-def _show_latest_episodes(plugin_name: str, page: int = 1) -> None:
- handle = _get_handle()
- plugin_name = (plugin_name or "").strip()
- plugin = _discover_plugins().get(plugin_name)
- if not plugin:
- xbmcgui.Dialog().notification("Neueste Folgen", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- getter = getattr(plugin, "latest_episodes", None)
- if not callable(getter):
- xbmcgui.Dialog().notification("Neueste Folgen", "Nicht unterstützt.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- xbmcplugin.setPluginCategory(handle, f"{plugin_name}: Neueste Folgen")
- _set_content(handle, "episodes")
-
- try:
- with _busy_dialog():
- entries = list(getter(page) or [])
- except Exception as exc:
- _log(f"Neueste Folgen fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
- xbmcgui.Dialog().notification("Neueste Folgen", "Abruf fehlgeschlagen.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- for entry in entries:
- try:
- title = str(getattr(entry, "series_title", "") or "").strip()
- season_number = int(getattr(entry, "season", 0) or 0)
- episode_number = int(getattr(entry, "episode", 0) or 0)
- url = str(getattr(entry, "url", "") or "").strip()
- airdate = str(getattr(entry, "airdate", "") or "").strip()
- except Exception:
- continue
- if not title or not url or season_number < 0 or episode_number <= 0:
- continue
-
- season_label = f"Staffel {season_number}"
- episode_label = f"Episode {episode_number}"
- key = _playstate_key(plugin_name=plugin_name, title=title, season=season_label, episode=episode_label)
- playstate = _get_playstate(key)
-
- label = f"{title} - S{season_number:02d}E{episode_number:02d}"
- if airdate:
- label = f"{label} ({airdate})"
- label = _label_with_playstate(label, playstate)
-
- info_labels: dict[str, object] = {
- "title": f"{title} - S{season_number:02d}E{episode_number:02d}",
- "tvshowtitle": title,
- "season": season_number,
- "episode": episode_number,
- "mediatype": "episode",
- }
- info_labels = _apply_playstate_to_info(info_labels, playstate)
-
- _add_directory_item(
- handle,
- label,
- "play_episode_url",
- {
- "plugin": plugin_name,
- "title": title,
- "season": str(season_number),
- "episode": str(episode_number),
- "url": url,
- },
- is_folder=False,
- info_labels=info_labels,
- )
-
- xbmcplugin.endOfDirectory(handle)
-
-
-def _show_genre_series_group(plugin_name: str, genre: str, group_code: str, page: int = 1) -> None:
- handle = _get_handle()
- page_size = 10
- page = max(1, int(page or 1))
-
- try:
- titles = _get_genre_titles(plugin_name, genre)
- except Exception as exc:
- _log(f"Genre-Serien konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
- xbmcgui.Dialog().notification("Genres", "Serien konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
- xbmcplugin.endOfDirectory(handle)
- return
-
- filtered = [title for title in titles if _group_matches(group_code, title)]
- total = len(filtered)
- total_pages = max(1, (total + page_size - 1) // page_size)
- page = min(page, total_pages)
- xbmcplugin.setPluginCategory(handle, f"{genre} [{group_code}] ({page}/{total_pages})")
-
- if total_pages > 1 and page > 1:
- _add_directory_item(
- handle,
- "Vorherige Seite",
- "genre_series_group",
- {"plugin": plugin_name, "genre": genre, "group": group_code, "page": str(page - 1)},
- is_folder=True,
- )
-
- start = (page - 1) * page_size
- end = start + page_size
- page_items = filtered[start:end]
- show_tmdb = _get_setting_bool("tmdb_genre_metadata", default=False)
-
- if page_items:
- if show_tmdb:
- with _busy_dialog():
- tmdb_prefetched = _tmdb_labels_and_art_bulk(page_items)
- for title in page_items:
- info_labels, art, cast = tmdb_prefetched.get(title, _tmdb_labels_and_art(title))
- info_labels = dict(info_labels or {})
- info_labels.setdefault("mediatype", "tvshow")
- if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
- info_labels.setdefault("tvshowtitle", title)
- playstate = _title_playstate(plugin_name, title)
- info_labels = _apply_playstate_to_info(dict(info_labels), playstate)
- display_label = _label_with_duration(title, info_labels)
- display_label = _label_with_playstate(display_label, playstate)
- _add_directory_item(
- handle,
- display_label,
- "seasons",
- {"plugin": plugin_name, "title": title},
- is_folder=True,
- info_labels=info_labels,
- art=art,
- cast=cast,
- )
- else:
- for title in page_items:
- playstate = _title_playstate(plugin_name, title)
- _add_directory_item(
- handle,
- _label_with_playstate(title, playstate),
- "seasons",
- {"plugin": plugin_name, "title": title},
- is_folder=True,
- info_labels=_apply_playstate_to_info({"title": title}, playstate),
- )
-
- if total_pages > 1 and page < total_pages:
- _add_directory_item(
- handle,
- "Nächste Seite",
- "genre_series_group",
- {"plugin": plugin_name, "genre": genre, "group": group_code, "page": str(page + 1)},
- is_folder=True,
- )
- xbmcplugin.endOfDirectory(handle)
-
-def _open_settings() -> None:
- """Oeffnet das Kodi-Addon-Settings-Dialog."""
- if xbmcaddon is None: # pragma: no cover - outside Kodi
- raise RuntimeError("xbmcaddon ist nicht verfuegbar (KodiStub).")
- addon = xbmcaddon.Addon()
- addon.openSettings()
-
-
-def _extract_first_int(value: str) -> int | None:
- match = re.search(r"(\d+)", value or "")
- if not match:
- return None
- try:
- return int(match.group(1))
- except Exception:
- return None
-
-
-def _duration_label(duration_seconds: int) -> str:
- try:
- duration_seconds = int(duration_seconds or 0)
- except Exception:
- duration_seconds = 0
- if duration_seconds <= 0:
- return ""
- total_minutes = max(0, duration_seconds // 60)
- hours = max(0, total_minutes // 60)
- minutes = max(0, total_minutes % 60)
- return f"{hours:02d}:{minutes:02d} Laufzeit"
-
-
-def _label_with_duration(label: str, info_labels: dict[str, str] | None) -> str:
- return label
-
-
-def _play_final_link(
- link: str,
- *,
- display_title: str | None = None,
- info_labels: dict[str, str] | None = None,
- art: dict[str, str] | None = None,
- cast: list[TmdbCastMember] | None = None,
- resolve_handle: int | None = None,
-) -> None:
- list_item = xbmcgui.ListItem(label=display_title or "", path=link)
- try:
- list_item.setProperty("IsPlayable", "true")
- except Exception:
- pass
- merged_info: dict[str, object] = dict(info_labels or {})
- if display_title:
- merged_info["title"] = display_title
- _apply_video_info(list_item, merged_info, cast)
- if art:
- setter = getattr(list_item, "setArt", None)
- if callable(setter):
- try:
- setter(art)
- except Exception:
- pass
-
- # Bei Plugin-Play-Items sollte Kodi via setResolvedUrl() die Wiedergabe starten.
- # player.play() kann dazu führen, dass Kodi den Item-Callback nochmal triggert (Hoster-Auswahl doppelt).
- resolved = False
- if resolve_handle is not None:
- resolver = getattr(xbmcplugin, "setResolvedUrl", None)
- if callable(resolver):
- try:
- resolver(resolve_handle, True, list_item)
- resolved = True
- except Exception:
- pass
-
- if not resolved:
- player = xbmc.Player()
- player.play(item=link, listitem=list_item)
-
-
-def _track_playback_and_update_state(key: str) -> None:
- if not key:
- return
- monitor = xbmc.Monitor() if xbmc is not None and hasattr(xbmc, "Monitor") else None
- player = xbmc.Player()
-
- # Wait for playback start.
- started = False
- for _ in range(30):
- try:
- if player.isPlayingVideo():
- started = True
- break
- except Exception:
- pass
- if monitor and monitor.waitForAbort(0.5):
- return
- if not started:
- return
-
- last_pos = 0.0
- total = 0.0
- while True:
- try:
- if not player.isPlayingVideo():
- break
- last_pos = float(player.getTime() or 0.0)
- total = float(player.getTotalTime() or 0.0)
- except Exception:
- pass
- if monitor and monitor.waitForAbort(1.0):
- return
-
- if total <= 0.0:
- return
- percent = max(0.0, min(1.0, last_pos / total))
- state: dict[str, object] = {"last_position": int(last_pos), "resume_total": int(total), "percent": percent}
- if percent >= WATCHED_THRESHOLD:
- state["watched"] = True
- state["resume_position"] = 0
- elif last_pos > 0:
- state["watched"] = False
- state["resume_position"] = int(last_pos)
- _set_playstate(key, state)
-
- # Zusätzlich aggregiert speichern, damit Titel-/Staffel-Listen "gesehen/fortsetzen"
- # anzeigen können (für Filme/Serien gleichermaßen).
- try:
- parts = str(key).split("\t")
- if len(parts) == 4:
- plugin_name, title, season, _episode = parts
- plugin_name = (plugin_name or "").strip()
- title = (title or "").strip()
- season = (season or "").strip()
- if plugin_name and title:
- _set_playstate(_playstate_key(plugin_name=plugin_name, title=title, season="", episode=""), state)
- if season:
- _set_playstate(_playstate_key(plugin_name=plugin_name, title=title, season=season, episode=""), state)
- except Exception:
- pass
-
-
-def _play_episode(
- plugin_name: str,
- title: str,
- season: str,
- episode: str,
- *,
- resolve_handle: int | None = None,
-) -> None:
- _log(f"Play anfordern: {plugin_name} / {title} / {season} / {episode}")
- plugin = _discover_plugins().get(plugin_name)
- if plugin is None:
- xbmcgui.Dialog().notification("Play", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- return
-
- available_hosters: list[str] = []
- hoster_getter = getattr(plugin, "available_hosters_for", None)
- if callable(hoster_getter):
- try:
- with _busy_dialog():
- available_hosters = list(hoster_getter(title, season, episode) or [])
- except Exception as exc:
- _log(f"Hoster laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
-
- selected_hoster: str | None = None
- if available_hosters:
- if len(available_hosters) == 1:
- selected_hoster = available_hosters[0]
- else:
- selected_index = xbmcgui.Dialog().select("Hoster wählen", available_hosters)
- if selected_index is None or selected_index < 0:
- _log("Play abgebrochen (kein Hoster gewählt).", xbmc.LOGDEBUG)
- return
- selected_hoster = available_hosters[selected_index]
-
- # Manche Plugins erlauben (optional) eine temporaere Einschränkung auf einen Hoster.
- preferred_setter = getattr(plugin, "set_preferred_hosters", None)
- restore_hosters: list[str] | None = None
- if selected_hoster and callable(preferred_setter):
- current = getattr(plugin, "_preferred_hosters", None)
- if isinstance(current, list):
- restore_hosters = list(current)
- preferred_setter([selected_hoster])
-
- try:
- link = plugin.stream_link_for(title, season, episode)
- if not link:
- _log("Kein Stream-Link gefunden.", xbmc.LOGWARNING)
- xbmcgui.Dialog().notification("Play", "Kein Stream-Link gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- return
- _log(f"Stream-Link: {link}", xbmc.LOGDEBUG)
- final_link = plugin.resolve_stream_link(link) or link
- finally:
- if restore_hosters is not None and callable(preferred_setter):
- preferred_setter(restore_hosters)
-
- _log(f"Finaler Link: {final_link}", xbmc.LOGDEBUG)
- season_number = _extract_first_int(season)
- episode_number = _extract_first_int(episode)
- if season_number is not None and episode_number is not None:
- display_title = f"{title} - S{season_number:02d}E{episode_number:02d}"
- else:
- display_title = title
- info_labels, art, cast = _tmdb_labels_and_art(title)
- display_title = _label_with_duration(display_title, info_labels)
- _play_final_link(
- final_link,
- display_title=display_title,
- info_labels=info_labels,
- art=art,
- cast=cast,
- resolve_handle=resolve_handle,
- )
- _track_playback_and_update_state(
- _playstate_key(plugin_name=plugin_name, title=title, season=season, episode=episode)
- )
-
-
-def _play_episode_url(
- plugin_name: str,
- *,
- title: str,
- season_number: int,
- episode_number: int,
- episode_url: str,
- resolve_handle: int | None = None,
-) -> None:
- season_label = f"Staffel {season_number}" if season_number > 0 else ""
- episode_label = f"Episode {episode_number}" if episode_number > 0 else ""
- _log(f"Play (URL) anfordern: {plugin_name} / {title} / {season_label} / {episode_label} / {episode_url}")
- plugin = _discover_plugins().get(plugin_name)
- if plugin is None:
- xbmcgui.Dialog().notification("Play", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- return
-
- available_hosters: list[str] = []
- hoster_getter = getattr(plugin, "available_hosters_for_url", None)
- if callable(hoster_getter):
- try:
- with _busy_dialog():
- available_hosters = list(hoster_getter(episode_url) or [])
- except Exception as exc:
- _log(f"Hoster laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
-
- selected_hoster: str | None = None
- if available_hosters:
- if len(available_hosters) == 1:
- selected_hoster = available_hosters[0]
- else:
- selected_index = xbmcgui.Dialog().select("Hoster wählen", available_hosters)
- if selected_index is None or selected_index < 0:
- _log("Play abgebrochen (kein Hoster gewählt).", xbmc.LOGDEBUG)
- return
- selected_hoster = available_hosters[selected_index]
-
- preferred_setter = getattr(plugin, "set_preferred_hosters", None)
- restore_hosters: list[str] | None = None
- if selected_hoster and callable(preferred_setter):
- current = getattr(plugin, "_preferred_hosters", None)
- if isinstance(current, list):
- restore_hosters = list(current)
- preferred_setter([selected_hoster])
-
- try:
- link_getter = getattr(plugin, "stream_link_for_url", None)
- if not callable(link_getter):
- xbmcgui.Dialog().notification("Play", "Nicht unterstützt.", xbmcgui.NOTIFICATION_INFO, 3000)
- return
- link = link_getter(episode_url)
- if not link:
- _log("Kein Stream-Link gefunden.", xbmc.LOGWARNING)
- xbmcgui.Dialog().notification("Play", "Kein Stream-Link gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
- return
- _log(f"Stream-Link: {link}", xbmc.LOGDEBUG)
- final_link = plugin.resolve_stream_link(link) or link
- finally:
- if restore_hosters is not None and callable(preferred_setter):
- preferred_setter(restore_hosters)
-
- display_title = f"{title} - S{season_number:02d}E{episode_number:02d}" if season_number and episode_number else title
- info_labels, art, cast = _tmdb_labels_and_art(title)
- info_labels = dict(info_labels or {})
- info_labels.setdefault("mediatype", "episode")
- info_labels.setdefault("tvshowtitle", title)
- if season_number > 0:
- info_labels["season"] = str(season_number)
- if episode_number > 0:
- info_labels["episode"] = str(episode_number)
- display_title = _label_with_duration(display_title, info_labels)
- _play_final_link(
- final_link,
- display_title=display_title,
- info_labels=info_labels,
- art=art,
- cast=cast,
- resolve_handle=resolve_handle,
- )
- _track_playback_and_update_state(
- _playstate_key(plugin_name=plugin_name, title=title, season=season_label, episode=episode_label)
- )
-
-
-def _parse_params() -> dict[str, str]:
- """Parst Kodi-Plugin-Parameter aus `sys.argv[2]`."""
- if len(sys.argv) <= 2 or not sys.argv[2]:
- return {}
- raw_params = parse_qs(sys.argv[2].lstrip("?"), keep_blank_values=True)
- return {key: values[0] for key, values in raw_params.items()}
-
-
-def run() -> None:
- params = _parse_params()
- action = params.get("action")
- _log(f"Action: {action}", xbmc.LOGDEBUG)
- if action == "search":
- _show_search()
- elif action == "plugin_menu":
- _show_plugin_menu(params.get("plugin", ""))
- elif action == "plugin_search":
- _show_plugin_search(params.get("plugin", ""))
- elif action == "genre_sources":
- _show_genre_sources()
- elif action == "genres":
- _show_genres(params.get("plugin", ""))
- elif action == "new_titles":
- _show_new_titles(
- params.get("plugin", ""),
- _parse_positive_int(params.get("page", "1"), default=1),
- )
- elif action == "latest_episodes":
- _show_latest_episodes(
- params.get("plugin", ""),
- _parse_positive_int(params.get("page", "1"), default=1),
- )
- elif action == "genre_series":
- _show_genre_series(
- params.get("plugin", ""),
- params.get("genre", ""),
- )
- elif action == "genre_titles_page":
- _show_genre_titles_page(
- params.get("plugin", ""),
- params.get("genre", ""),
- _parse_positive_int(params.get("page", "1"), default=1),
- )
- elif action == "genre_series_group":
- _show_genre_series_group(
- params.get("plugin", ""),
- params.get("genre", ""),
- params.get("group", ""),
- _parse_positive_int(params.get("page", "1"), default=1),
- )
- elif action == "popular":
- _show_popular(
- params.get("plugin") or None,
- _parse_positive_int(params.get("page", "1"), default=1),
- )
- elif action == "settings":
- _open_settings()
- elif action == "seasons":
- _show_seasons(params.get("plugin", ""), params.get("title", ""))
- elif action == "episodes":
- _show_episodes(
- params.get("plugin", ""),
- params.get("title", ""),
- params.get("season", ""),
- )
- elif action == "play_episode":
- _play_episode(
- params.get("plugin", ""),
- params.get("title", ""),
- params.get("season", ""),
- params.get("episode", ""),
- resolve_handle=_get_handle(),
- )
- elif action == "play_movie":
- plugin_name = params.get("plugin", "")
- title = params.get("title", "")
- # Einschalten liefert Filme (keine Staffeln/Episoden). Für Playback nutzen wir:
- # -> Stream -> .
- if (plugin_name or "").casefold() == "einschalten":
- _play_episode(
- plugin_name,
- title,
- "Stream",
- title,
- resolve_handle=_get_handle(),
- )
- else:
- _play_episode(
- plugin_name,
- title,
- "Film",
- "Stream",
- resolve_handle=_get_handle(),
- )
- elif action == "play_episode_url":
- _play_episode_url(
- params.get("plugin", ""),
- title=params.get("title", ""),
- season_number=_parse_positive_int(params.get("season", "0"), default=0),
- episode_number=_parse_positive_int(params.get("episode", "0"), default=0),
- episode_url=params.get("url", ""),
- resolve_handle=_get_handle(),
- )
- elif action == "play":
- link = params.get("url", "")
- if link:
- _play_final_link(link, resolve_handle=_get_handle())
- else:
- _show_root_menu()
-
-
-if __name__ == "__main__":
- run()
diff --git a/dist/plugin.video.viewit/http_session_pool.py b/dist/plugin.video.viewit/http_session_pool.py
deleted file mode 100644
index 725fa43..0000000
--- a/dist/plugin.video.viewit/http_session_pool.py
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/usr/bin/env python3
-"""Shared requests.Session pooling for plugins.
-
-Goal: reuse TCP connections/cookies across multiple HTTP calls within a Kodi session.
-"""
-
-from __future__ import annotations
-
-from typing import Any, Dict, Optional
-
-try: # pragma: no cover - optional dependency
- import requests
-except Exception: # pragma: no cover
- requests = None
-
-_SESSIONS: Dict[str, Any] = {}
-
-
-def get_requests_session(key: str, *, headers: Optional[dict[str, str]] = None):
- """Return a cached `requests.Session()` for the given key."""
- if requests is None:
- raise RuntimeError("requests ist nicht verfuegbar.")
- key = (key or "").strip() or "default"
- session = _SESSIONS.get(key)
- if session is None:
- session = requests.Session()
- _SESSIONS[key] = session
- if headers:
- try:
- session.headers.update({str(k): str(v) for k, v in headers.items() if k and v})
- except Exception:
- pass
- return session
-
diff --git a/dist/plugin.video.viewit/icon.png b/dist/plugin.video.viewit/icon.png
deleted file mode 100644
index 9e65f73..0000000
Binary files a/dist/plugin.video.viewit/icon.png and /dev/null differ
diff --git a/dist/plugin.video.viewit/plugin_helpers.py b/dist/plugin.video.viewit/plugin_helpers.py
deleted file mode 100644
index ef634c0..0000000
--- a/dist/plugin.video.viewit/plugin_helpers.py
+++ /dev/null
@@ -1,128 +0,0 @@
-#!/usr/bin/env python3
-"""Shared helpers for ViewIt plugins.
-
-Focus:
-- Kodi addon settings access (string/bool)
-- Optional URL notifications
-- Optional URL logging
-- Optional HTML response dumps
-
-Designed to work both in Kodi and outside Kodi (for linting/tests).
-"""
-
-from __future__ import annotations
-
-from datetime import datetime
-import hashlib
-import os
-from typing import Optional
-
-try: # pragma: no cover - Kodi runtime
- import xbmcaddon # type: ignore[import-not-found]
- import xbmcvfs # type: ignore[import-not-found]
- import xbmcgui # type: ignore[import-not-found]
-except ImportError: # pragma: no cover - allow importing outside Kodi
- xbmcaddon = None
- xbmcvfs = None
- xbmcgui = None
-
-
-def get_setting_string(addon_id: str, setting_id: str, *, default: str = "") -> str:
- if xbmcaddon is None:
- return default
- try:
- addon = xbmcaddon.Addon(addon_id)
- getter = getattr(addon, "getSettingString", None)
- if getter is not None:
- return str(getter(setting_id) or "").strip()
- return str(addon.getSetting(setting_id) or "").strip()
- except Exception:
- return default
-
-
-def get_setting_bool(addon_id: str, setting_id: str, *, default: bool = False) -> bool:
- if xbmcaddon is None:
- return default
- try:
- addon = xbmcaddon.Addon(addon_id)
- getter = getattr(addon, "getSettingBool", None)
- if getter is not None:
- return bool(getter(setting_id))
- raw = addon.getSetting(setting_id)
- return str(raw).strip().lower() in {"1", "true", "yes", "on"}
- except Exception:
- return default
-
-
-def notify_url(addon_id: str, *, heading: str, url: str, enabled_setting_id: str) -> None:
- if xbmcgui is None:
- return
- if not get_setting_bool(addon_id, enabled_setting_id, default=False):
- return
- try:
- xbmcgui.Dialog().notification(heading, url, xbmcgui.NOTIFICATION_INFO, 3000)
- except Exception:
- return
-
-
-def _profile_logs_dir(addon_id: str) -> Optional[str]:
- if xbmcaddon is None or xbmcvfs is None:
- return None
- try:
- addon = xbmcaddon.Addon(addon_id)
- profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
- log_dir = os.path.join(profile, "logs")
- if not xbmcvfs.exists(log_dir):
- xbmcvfs.mkdirs(log_dir)
- return log_dir
- except Exception:
- return None
-
-
-def _append_text_file(path: str, content: str) -> None:
- try:
- with open(path, "a", encoding="utf-8") as handle:
- handle.write(content)
- return
- except Exception:
- pass
- if xbmcvfs is None:
- return
- try:
- handle = xbmcvfs.File(path, "a")
- handle.write(content)
- handle.close()
- except Exception:
- return
-
-
-def log_url(addon_id: str, *, enabled_setting_id: str, log_filename: str, url: str, kind: str = "VISIT") -> None:
- if not get_setting_bool(addon_id, enabled_setting_id, default=False):
- return
- timestamp = datetime.utcnow().isoformat(timespec="seconds") + "Z"
- line = f"{timestamp}\t{kind}\t{url}\n"
- log_dir = _profile_logs_dir(addon_id)
- if log_dir:
- _append_text_file(os.path.join(log_dir, log_filename), line)
- return
- _append_text_file(os.path.join(os.path.dirname(__file__), log_filename), line)
-
-
-def dump_response_html(
- addon_id: str,
- *,
- enabled_setting_id: str,
- url: str,
- body: str,
- filename_prefix: str,
-) -> None:
- if not get_setting_bool(addon_id, enabled_setting_id, default=False):
- return
- timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f")
- digest = hashlib.md5(url.encode("utf-8")).hexdigest() # nosec - filename only
- filename = f"{filename_prefix}_{timestamp}_{digest}.html"
- log_dir = _profile_logs_dir(addon_id)
- path = os.path.join(log_dir, filename) if log_dir else os.path.join(os.path.dirname(__file__), filename)
- content = f"\n{body or ''}"
- _append_text_file(path, content)
-
diff --git a/dist/plugin.video.viewit/plugin_interface.py b/dist/plugin.video.viewit/plugin_interface.py
deleted file mode 100644
index a8b5b37..0000000
--- a/dist/plugin.video.viewit/plugin_interface.py
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/usr/bin/env python3
-"""Gemeinsame Schnittstelle fuer Kodi-Plugins."""
-
-from __future__ import annotations
-
-from abc import ABC, abstractmethod
-from typing import List, Optional, Set
-
-
-class BasisPlugin(ABC):
- """Abstrakte Basisklasse fuer alle Integrationen."""
-
- name: str
-
- @abstractmethod
- async def search_titles(self, query: str) -> List[str]:
- """Liefert eine Liste aller Treffer fuer die Suche."""
-
- @abstractmethod
- def seasons_for(self, title: str) -> List[str]:
- """Liefert alle Staffeln zu einem Titel."""
-
- @abstractmethod
- def episodes_for(self, title: str, season: str) -> List[str]:
- """Liefert alle Folgen zu einer Staffel."""
-
- def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]:
- """Optional: Liefert den Stream-Link fuer eine konkrete Folge."""
- return None
-
- def resolve_stream_link(self, link: str) -> Optional[str]:
- """Optional: Folgt einem Stream-Link und liefert die finale URL."""
- return None
-
- def genres(self) -> List[str]:
- """Optional: Liefert eine Liste an Genres (falls verfügbar)."""
- return []
-
- def titles_for_genre(self, genre: str) -> List[str]:
- """Optional: Liefert alle Serientitel zu einem Genre."""
- return []
-
- def capabilities(self) -> Set[str]:
- """Optional: Liefert eine Menge an Features/Capabilities dieses Plugins.
-
- Beispiele:
- - `popular_series`: Plugin kann eine Liste beliebter Serien liefern.
- """
-
- return set()
-
- def popular_series(self) -> List[str]:
- """Optional: Liefert eine Liste beliebter Serien (als Titel-Strings)."""
-
- return []
diff --git a/dist/plugin.video.viewit/plugins/__init__.py b/dist/plugin.video.viewit/plugins/__init__.py
deleted file mode 100644
index 9929cfa..0000000
--- a/dist/plugin.video.viewit/plugins/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Kodi addon plugins."""
diff --git a/dist/plugin.video.viewit/plugins/_template_plugin.py b/dist/plugin.video.viewit/plugins/_template_plugin.py
deleted file mode 100644
index a5244e2..0000000
--- a/dist/plugin.video.viewit/plugins/_template_plugin.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""Template fuer ein neues ViewIt-Plugin (Basis: serienstream_plugin).
-
-Diese Datei wird NICHT automatisch geladen (Dateiname beginnt mit `_`).
-Zum Verwenden:
-1) Kopiere/benenne die Datei um (ohne fuehrenden Unterstrich), z.B. `my_site_plugin.py`
-2) Passe `name`, `BASE_URL` und die Implementierungen an.
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, List, Optional, TypeAlias
-
-try: # pragma: no cover - optional dependency
- import requests
- from bs4 import BeautifulSoup # type: ignore[import-not-found]
-except ImportError as exc: # pragma: no cover - optional dependency
- requests = None
- BeautifulSoup = None
- REQUESTS_AVAILABLE = False
- REQUESTS_IMPORT_ERROR = exc
-else:
- REQUESTS_AVAILABLE = True
- REQUESTS_IMPORT_ERROR = None
-
-try: # pragma: no cover - optional Kodi helpers
- import xbmcaddon # type: ignore[import-not-found]
-except ImportError: # pragma: no cover - allow running outside Kodi
- xbmcaddon = None
-
-from plugin_interface import BasisPlugin
-
-if TYPE_CHECKING: # pragma: no cover
- from requests import Session as RequestsSession
- from bs4 import BeautifulSoup as BeautifulSoupT # type: ignore[import-not-found]
-else: # pragma: no cover
- RequestsSession: TypeAlias = Any
- BeautifulSoupT: TypeAlias = Any
-
-
-ADDON_ID = "plugin.video.viewit"
-BASE_URL = "https://example.com"
-DEFAULT_TIMEOUT = 20
-HEADERS = {
- "User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)",
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
- "Connection": "keep-alive",
-}
-
-
-@dataclass(frozen=True)
-class TitleHit:
- """Ein Suchtreffer mit Titel und Detail-URL."""
-
- title: str
- url: str
-
-
-class TemplatePlugin(BasisPlugin):
- """Vorlage fuer eine Streamingseiten-Integration.
-
- Optional kann ein Plugin Capabilities deklarieren (z.B. `popular_series`),
- damit der Router passende Menüpunkte anbieten kann.
- """
-
- name = "Template"
-
- def __init__(self) -> None:
- self._session: RequestsSession | None = None
-
- @property
- def is_available(self) -> bool:
- return REQUESTS_AVAILABLE
-
- @property
- def unavailable_reason(self) -> str:
- if REQUESTS_AVAILABLE:
- return ""
- return f"requests/bs4 nicht verfuegbar: {REQUESTS_IMPORT_ERROR}"
-
- def _get_session(self) -> RequestsSession:
- if requests is None:
- raise RuntimeError(self.unavailable_reason)
- if self._session is None:
- session = requests.Session()
- session.headers.update(HEADERS)
- self._session = session
- return self._session
-
- async def search_titles(self, query: str) -> List[str]:
- """TODO: Suche auf der Zielseite implementieren."""
- _ = query
- return []
-
- def seasons_for(self, title: str) -> List[str]:
- """TODO: Staffeln fuer einen Titel liefern."""
- _ = title
- return []
-
- def episodes_for(self, title: str, season: str) -> List[str]:
- """TODO: Episoden fuer Titel+Staffel liefern."""
- _ = (title, season)
- return []
-
- def capabilities(self) -> set[str]:
- """Optional: Deklariert Fähigkeiten dieses Plugins.
-
- Beispiele:
- - `popular_series`: Plugin kann beliebte Serien liefern
- - `genres`: Plugin unterstützt Genre-Browser
- """
-
- return set()
-
- def popular_series(self) -> List[str]:
- """Optional: Liste beliebter Serien (nur wenn `popular_series` gesetzt ist)."""
- return []
-
- def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]:
- """Optional: Embed-/Hoster-Link fuer eine Episode."""
- _ = (title, season, episode)
- return None
-
- def resolve_stream_link(self, link: str) -> Optional[str]:
- """Optional: Redirect-/Mirror-Aufloesung."""
- return link
diff --git a/dist/plugin.video.viewit/plugins/aniworld_plugin.py b/dist/plugin.video.viewit/plugins/aniworld_plugin.py
deleted file mode 100644
index 99d7a65..0000000
--- a/dist/plugin.video.viewit/plugins/aniworld_plugin.py
+++ /dev/null
@@ -1,877 +0,0 @@
-"""AniWorld (aniworld.to) Integration als Downloader-Plugin.
-
-Dieses Plugin ist weitgehend kompatibel zur Serienstream-Integration:
-- gleiche Staffel-/Episoden-URL-Struktur (/staffel-x/episode-y)
-- gleiche Hoster-/Watch-Layouts (best-effort)
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-import re
-from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, TypeAlias
-
-try: # pragma: no cover - optional dependency
- import requests
- from bs4 import BeautifulSoup # type: ignore[import-not-found]
-except ImportError as exc: # pragma: no cover - optional dependency
- requests = None
- BeautifulSoup = None
- REQUESTS_AVAILABLE = False
- REQUESTS_IMPORT_ERROR = exc
-else:
- REQUESTS_AVAILABLE = True
- REQUESTS_IMPORT_ERROR = None
-
-try: # pragma: no cover - optional Kodi helpers
- import xbmcaddon # type: ignore[import-not-found]
-except ImportError: # pragma: no cover - allow running outside Kodi
- xbmcaddon = None
-
-from plugin_interface import BasisPlugin
-from plugin_helpers import dump_response_html, get_setting_bool, log_url, notify_url
-from http_session_pool import get_requests_session
-from regex_patterns import DIGITS, SEASON_EPISODE_TAG, SEASON_EPISODE_URL, STAFFEL_NUM_IN_URL
-
-if TYPE_CHECKING: # pragma: no cover
- from requests import Session as RequestsSession
- from bs4 import BeautifulSoup as BeautifulSoupT # type: ignore[import-not-found]
-else: # pragma: no cover
- RequestsSession: TypeAlias = Any
- BeautifulSoupT: TypeAlias = Any
-
-
-BASE_URL = "https://aniworld.to"
-ANIME_BASE_URL = f"{BASE_URL}/anime/stream"
-POPULAR_ANIMES_URL = f"{BASE_URL}/beliebte-animes"
-GENRES_URL = f"{BASE_URL}/animes"
-LATEST_EPISODES_URL = f"{BASE_URL}/neue-episoden"
-SEARCH_URL = f"{BASE_URL}/search?q={{query}}"
-SEARCH_API_URL = f"{BASE_URL}/ajax/search"
-DEFAULT_PREFERRED_HOSTERS = ["voe"]
-DEFAULT_TIMEOUT = 20
-ADDON_ID = "plugin.video.viewit"
-GLOBAL_SETTING_LOG_URLS = "debug_log_urls"
-GLOBAL_SETTING_DUMP_HTML = "debug_dump_html"
-GLOBAL_SETTING_SHOW_URL_INFO = "debug_show_url_info"
-HEADERS = {
- "User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)",
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
- "Connection": "keep-alive",
-}
-
-
-@dataclass
-class SeriesResult:
- title: str
- description: str
- url: str
-
-
-@dataclass
-class EpisodeInfo:
- number: int
- title: str
- original_title: str
- url: str
-
-
-@dataclass
-class LatestEpisode:
- series_title: str
- season: int
- episode: int
- url: str
- airdate: str
-
-
-@dataclass
-class SeasonInfo:
- number: int
- url: str
- episodes: List[EpisodeInfo]
-
-
-def _absolute_url(href: str) -> str:
- return f"{BASE_URL}{href}" if href.startswith("/") else href
-
-
-def _log_url(url: str, *, kind: str = "VISIT") -> None:
- log_url(ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, log_filename="aniworld_urls.log", url=url, kind=kind)
-
-
-def _log_visit(url: str) -> None:
- _log_url(url, kind="VISIT")
- notify_url(ADDON_ID, heading="AniWorld", url=url, enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO)
-
-
-def _log_parsed_url(url: str) -> None:
- _log_url(url, kind="PARSE")
-
-
-def _log_response_html(url: str, body: str) -> None:
- dump_response_html(
- ADDON_ID,
- enabled_setting_id=GLOBAL_SETTING_DUMP_HTML,
- url=url,
- body=body,
- filename_prefix="aniworld_response",
- )
-
-
-def _normalize_search_text(value: str) -> str:
- value = (value or "").casefold()
- value = re.sub(r"[^a-z0-9]+", " ", value)
- value = re.sub(r"\s+", " ", value).strip()
- return value
-
-
-def _strip_html(text: str) -> str:
- if not text:
- return ""
- return re.sub(r"<[^>]+>", "", text)
-
-
-def _matches_query(query: str, *, title: str) -> bool:
- normalized_query = _normalize_search_text(query)
- if not normalized_query:
- return False
- haystack = _normalize_search_text(title)
- if not haystack:
- return False
- return normalized_query in haystack
-
-
-def _ensure_requests() -> None:
- if requests is None or BeautifulSoup is None:
- raise RuntimeError("requests/bs4 sind nicht verfuegbar.")
-
-
-def _looks_like_cloudflare_challenge(body: str) -> bool:
- lower = body.lower()
- markers = (
- "cf-browser-verification",
- "cf-challenge",
- "cf_chl",
- "challenge-platform",
- "attention required! | cloudflare",
- "just a moment...",
- "cloudflare ray id",
- )
- return any(marker in lower for marker in markers)
-
-
-def _get_soup(url: str, *, session: Optional[RequestsSession] = None) -> BeautifulSoupT:
- _ensure_requests()
- _log_visit(url)
- sess = session or get_requests_session("aniworld", headers=HEADERS)
- response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
- response.raise_for_status()
- if response.url and response.url != url:
- _log_url(response.url, kind="REDIRECT")
- _log_response_html(url, response.text)
- if _looks_like_cloudflare_challenge(response.text):
- raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.")
- return BeautifulSoup(response.text, "html.parser")
-
-
-def _get_soup_simple(url: str) -> BeautifulSoupT:
- _ensure_requests()
- _log_visit(url)
- sess = get_requests_session("aniworld", headers=HEADERS)
- response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
- response.raise_for_status()
- if response.url and response.url != url:
- _log_url(response.url, kind="REDIRECT")
- _log_response_html(url, response.text)
- if _looks_like_cloudflare_challenge(response.text):
- raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.")
- return BeautifulSoup(response.text, "html.parser")
-
-
-def _post_json(url: str, *, payload: Dict[str, str], session: Optional[RequestsSession] = None) -> Any:
- _ensure_requests()
- _log_visit(url)
- sess = session or get_requests_session("aniworld", headers=HEADERS)
- response = sess.post(url, data=payload, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
- response.raise_for_status()
- if response.url and response.url != url:
- _log_url(response.url, kind="REDIRECT")
- _log_response_html(url, response.text)
- if _looks_like_cloudflare_challenge(response.text):
- raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.")
- try:
- return response.json()
- except Exception:
- return None
-
-
-def _extract_canonical_url(soup: BeautifulSoupT, fallback: str) -> str:
- canonical = soup.select_one('link[rel="canonical"][href]')
- href = (canonical.get("href") if canonical else "") or ""
- href = href.strip()
- if href.startswith("http://") or href.startswith("https://"):
- return href.rstrip("/")
- return fallback.rstrip("/")
-
-
-def _series_root_url(url: str) -> str:
- normalized = (url or "").strip().rstrip("/")
- normalized = re.sub(r"/staffel-\d+(?:/.*)?$", "", normalized)
- normalized = re.sub(r"/episode-\d+(?:/.*)?$", "", normalized)
- return normalized.rstrip("/")
-
-
-def _extract_season_links(soup: BeautifulSoupT) -> List[Tuple[int, str]]:
- season_links: List[Tuple[int, str]] = []
- seen_numbers: set[int] = set()
- for anchor in soup.select('.hosterSiteDirectNav a[href*="/staffel-"]'):
- href = anchor.get("href") or ""
- if "/episode-" in href:
- continue
- match = re.search(STAFFEL_NUM_IN_URL, href)
- if match:
- number = int(match.group(1))
- else:
- label = anchor.get_text(strip=True)
- if not label.isdigit():
- continue
- number = int(label)
- if number in seen_numbers:
- continue
- seen_numbers.add(number)
- season_url = _absolute_url(href)
- if season_url:
- _log_parsed_url(season_url)
- season_links.append((number, season_url))
- season_links.sort(key=lambda item: item[0])
- return season_links
-
-
-def _extract_number_of_seasons(soup: BeautifulSoupT) -> Optional[int]:
- tag = soup.select_one('meta[itemprop="numberOfSeasons"]')
- if not tag:
- return None
- content = (tag.get("content") or "").strip()
- if not content.isdigit():
- return None
- count = int(content)
- return count if count > 0 else None
-
-
-def _extract_episodes(soup: BeautifulSoupT) -> List[EpisodeInfo]:
- episodes: List[EpisodeInfo] = []
- rows = soup.select("table.seasonEpisodesList tbody tr")
- for index, row in enumerate(rows):
- cells = row.find_all("td")
- if not cells:
- continue
- episode_cell = cells[0]
- number_text = episode_cell.get_text(strip=True)
- digits = "".join(ch for ch in number_text if ch.isdigit())
- number = int(digits) if digits else index + 1
- link = episode_cell.find("a")
- href = link.get("href") if link else ""
- url = _absolute_url(href or "")
- if url:
- _log_parsed_url(url)
-
- title_tag = row.select_one(".seasonEpisodeTitle strong")
- original_tag = row.select_one(".seasonEpisodeTitle span")
- title = title_tag.get_text(strip=True) if title_tag else ""
- original_title = original_tag.get_text(strip=True) if original_tag else ""
-
- if url:
- episodes.append(EpisodeInfo(number=number, title=title, original_title=original_title, url=url))
- return episodes
-
-
-_LATEST_EPISODE_TAG_RE = re.compile(SEASON_EPISODE_TAG, re.IGNORECASE)
-_LATEST_EPISODE_URL_RE = re.compile(SEASON_EPISODE_URL, re.IGNORECASE)
-
-
-def _extract_latest_episodes(soup: BeautifulSoupT) -> List[LatestEpisode]:
- episodes: List[LatestEpisode] = []
- seen: set[str] = set()
-
- for anchor in soup.select(".newEpisodeList a[href]"):
- href = (anchor.get("href") or "").strip()
- if not href or "/anime/stream/" not in href:
- continue
- url = _absolute_url(href)
- if not url:
- continue
-
- title_tag = anchor.select_one("strong")
- series_title = (title_tag.get_text(strip=True) if title_tag else "").strip()
- if not series_title:
- continue
-
- season_number: Optional[int] = None
- episode_number: Optional[int] = None
-
- match = _LATEST_EPISODE_URL_RE.search(href)
- if match:
- season_number = int(match.group(1))
- episode_number = int(match.group(2))
-
- if season_number is None or episode_number is None:
- tag_node = (
- anchor.select_one("span.listTag.bigListTag.blue2")
- or anchor.select_one("span.listTag.blue2")
- or anchor.select_one("span.blue2")
- )
- tag_text = (tag_node.get_text(" ", strip=True) if tag_node else "").strip()
- match = _LATEST_EPISODE_TAG_RE.search(tag_text)
- if not match:
- continue
- season_number = int(match.group(1))
- episode_number = int(match.group(2))
-
- if season_number is None or episode_number is None:
- continue
-
- airdate_node = anchor.select_one("span.elementFloatRight")
- airdate = (airdate_node.get_text(" ", strip=True) if airdate_node else "").strip()
-
- key = f"{url}\t{season_number}\t{episode_number}"
- if key in seen:
- continue
- seen.add(key)
-
- _log_parsed_url(url)
- episodes.append(
- LatestEpisode(
- series_title=series_title,
- season=season_number,
- episode=episode_number,
- url=url,
- airdate=airdate,
- )
- )
-
- return episodes
-
-
-def scrape_anime_detail(anime_identifier: str, max_seasons: Optional[int] = None) -> List[SeasonInfo]:
- _ensure_requests()
- anime_url = _series_root_url(_absolute_url(anime_identifier))
- _log_url(anime_url, kind="ANIME")
- session = get_requests_session("aniworld", headers=HEADERS)
- try:
- _get_soup(BASE_URL, session=session)
- except Exception:
- pass
- soup = _get_soup(anime_url, session=session)
-
- base_anime_url = _series_root_url(_extract_canonical_url(soup, anime_url))
- season_links = _extract_season_links(soup)
- season_count = _extract_number_of_seasons(soup)
- if season_count and (not season_links or len(season_links) < season_count):
- existing = {number for number, _ in season_links}
- for number in range(1, season_count + 1):
- if number in existing:
- continue
- season_url = f"{base_anime_url}/staffel-{number}"
- _log_parsed_url(season_url)
- season_links.append((number, season_url))
- season_links.sort(key=lambda item: item[0])
- if max_seasons is not None:
- season_links = season_links[:max_seasons]
-
- seasons: List[SeasonInfo] = []
- for number, url in season_links:
- season_soup = _get_soup(url, session=session)
- episodes = _extract_episodes(season_soup)
- seasons.append(SeasonInfo(number=number, url=url, episodes=episodes))
- seasons.sort(key=lambda s: s.number)
- return seasons
-
-
-def resolve_redirect(target_url: str) -> Optional[str]:
- _ensure_requests()
- normalized_url = _absolute_url(target_url)
- _log_visit(normalized_url)
- session = get_requests_session("aniworld", headers=HEADERS)
- _get_soup(BASE_URL, session=session)
- response = session.get(normalized_url, headers=HEADERS, timeout=DEFAULT_TIMEOUT, allow_redirects=True)
- if response.url:
- _log_url(response.url, kind="RESOLVED")
- return response.url if response.url else None
-
-
-def fetch_episode_hoster_names(episode_url: str) -> List[str]:
- _ensure_requests()
- normalized_url = _absolute_url(episode_url)
- session = get_requests_session("aniworld", headers=HEADERS)
- _get_soup(BASE_URL, session=session)
- soup = _get_soup(normalized_url, session=session)
- names: List[str] = []
- seen: set[str] = set()
- for anchor in soup.select(".hosterSiteVideo a.watchEpisode"):
- title = anchor.select_one("h4")
- name = title.get_text(strip=True) if title else ""
- if not name:
- name = anchor.get_text(" ", strip=True)
- name = (name or "").strip()
- if name.lower().startswith("hoster "):
- name = name[7:].strip()
- href = anchor.get("href") or ""
- url = _absolute_url(href)
- if url:
- _log_parsed_url(url)
- key = name.casefold().strip()
- if not key or key in seen:
- continue
- seen.add(key)
- names.append(name)
- if names:
- _log_url(f"{normalized_url}#hosters={','.join(names)}", kind="HOSTERS")
- return names
-
-
-def fetch_episode_stream_link(
- episode_url: str,
- *,
- preferred_hosters: Optional[List[str]] = None,
-) -> Optional[str]:
- _ensure_requests()
- normalized_url = _absolute_url(episode_url)
- preferred = [hoster.lower() for hoster in (preferred_hosters or DEFAULT_PREFERRED_HOSTERS)]
- session = get_requests_session("aniworld", headers=HEADERS)
- _get_soup(BASE_URL, session=session)
- soup = _get_soup(normalized_url, session=session)
- candidates: List[Tuple[str, str]] = []
- for anchor in soup.select(".hosterSiteVideo a.watchEpisode"):
- name_tag = anchor.select_one("h4")
- name = name_tag.get_text(strip=True) if name_tag else ""
- href = anchor.get("href") or ""
- url = _absolute_url(href)
- if url:
- _log_parsed_url(url)
- if name and url:
- candidates.append((name, url))
- if not candidates:
- return None
- candidates.sort(key=lambda item: item[0].casefold())
- selected_url = None
- for wanted in preferred:
- for name, url in candidates:
- if wanted in name.casefold():
- selected_url = url
- break
- if selected_url:
- break
- if not selected_url:
- selected_url = candidates[0][1]
- resolved = resolve_redirect(selected_url) or selected_url
- return resolved
-
-
-def search_animes(query: str) -> List[SeriesResult]:
- _ensure_requests()
- query = (query or "").strip()
- if not query:
- return []
- session = get_requests_session("aniworld", headers=HEADERS)
- try:
- session.get(BASE_URL, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
- except Exception:
- pass
- data = _post_json(SEARCH_API_URL, payload={"keyword": query}, session=session)
- results: List[SeriesResult] = []
- seen: set[str] = set()
- if isinstance(data, list):
- for entry in data:
- if not isinstance(entry, dict):
- continue
- title = _strip_html((entry.get("title") or "").strip())
- if not title or not _matches_query(query, title=title):
- continue
- link = (entry.get("link") or "").strip()
- if not link.startswith("/anime/stream/"):
- continue
- if "/staffel-" in link or "/episode-" in link:
- continue
- if link.rstrip("/") == "/anime/stream":
- continue
- url = _absolute_url(link) if link else ""
- if url:
- _log_parsed_url(url)
- key = title.casefold().strip()
- if key in seen:
- continue
- seen.add(key)
- description = (entry.get("description") or "").strip()
- results.append(SeriesResult(title=title, description=description, url=url))
- return results
-
- soup = _get_soup_simple(SEARCH_URL.format(query=requests.utils.quote(query)))
- for anchor in soup.select("a[href^='/anime/stream/'][href]"):
- href = (anchor.get("href") or "").strip()
- if not href or "/staffel-" in href or "/episode-" in href:
- continue
- url = _absolute_url(href)
- if url:
- _log_parsed_url(url)
- title_node = anchor.select_one("h3") or anchor.select_one("strong")
- title = (title_node.get_text(" ", strip=True) if title_node else anchor.get_text(" ", strip=True)).strip()
- if not title:
- continue
- if not _matches_query(query, title=title):
- continue
- key = title.casefold().strip()
- if key in seen:
- continue
- seen.add(key)
- results.append(SeriesResult(title=title, description="", url=url))
- return results
-
-
-class AniworldPlugin(BasisPlugin):
- name = "AniWorld (aniworld.to)"
-
- def __init__(self) -> None:
- self._anime_results: Dict[str, SeriesResult] = {}
- self._season_cache: Dict[str, List[SeasonInfo]] = {}
- self._episode_label_cache: Dict[Tuple[str, str], Dict[str, EpisodeInfo]] = {}
- self._popular_cache: Optional[List[SeriesResult]] = None
- self._genre_cache: Optional[Dict[str, List[SeriesResult]]] = None
- self._latest_cache: Dict[int, List[LatestEpisode]] = {}
- self._latest_hoster_cache: Dict[str, List[str]] = {}
- self._requests_available = REQUESTS_AVAILABLE
- self._default_preferred_hosters: List[str] = list(DEFAULT_PREFERRED_HOSTERS)
- self._preferred_hosters: List[str] = list(self._default_preferred_hosters)
- self._hoster_cache: Dict[Tuple[str, str, str], List[str]] = {}
- self.is_available = True
- self.unavailable_reason: Optional[str] = None
- if not self._requests_available: # pragma: no cover - optional dependency
- self.is_available = False
- self.unavailable_reason = "requests/bs4 fehlen. Installiere 'requests' und 'beautifulsoup4'."
- if REQUESTS_IMPORT_ERROR:
- print(f"AniworldPlugin Importfehler: {REQUESTS_IMPORT_ERROR}")
-
- def capabilities(self) -> set[str]:
- return {"popular_series", "genres", "latest_episodes"}
-
- def _find_series_by_title(self, title: str) -> Optional[SeriesResult]:
- title = (title or "").strip()
- if not title:
- return None
-
- direct = self._anime_results.get(title)
- if direct:
- return direct
-
- wanted = title.casefold().strip()
-
- for candidate in self._anime_results.values():
- if candidate.title and candidate.title.casefold().strip() == wanted:
- return candidate
-
- try:
- for entry in self._ensure_popular():
- if entry.title and entry.title.casefold().strip() == wanted:
- self._anime_results[entry.title] = entry
- return entry
- except Exception:
- pass
-
- try:
- for entries in self._ensure_genres().values():
- for entry in entries:
- if entry.title and entry.title.casefold().strip() == wanted:
- self._anime_results[entry.title] = entry
- return entry
- except Exception:
- pass
-
- try:
- for entry in search_animes(title):
- if entry.title and entry.title.casefold().strip() == wanted:
- self._anime_results[entry.title] = entry
- return entry
- except Exception:
- pass
-
- return None
-
- def _ensure_popular(self) -> List[SeriesResult]:
- if self._popular_cache is not None:
- return list(self._popular_cache)
- soup = _get_soup_simple(POPULAR_ANIMES_URL)
- results: List[SeriesResult] = []
- seen: set[str] = set()
- for anchor in soup.select("div.seriesListContainer a[href^='/anime/stream/']"):
- href = (anchor.get("href") or "").strip()
- if not href or "/staffel-" in href or "/episode-" in href:
- continue
- url = _absolute_url(href)
- if url:
- _log_parsed_url(url)
- title_node = anchor.select_one("h3")
- title = (title_node.get_text(" ", strip=True) if title_node else "").strip()
- if not title:
- continue
- description = ""
- desc_node = anchor.select_one("small")
- if desc_node:
- description = desc_node.get_text(" ", strip=True).strip()
- key = title.casefold().strip()
- if key in seen:
- continue
- seen.add(key)
- results.append(SeriesResult(title=title, description=description, url=url))
- self._popular_cache = list(results)
- return list(results)
-
- def popular_series(self) -> List[str]:
- if not self._requests_available:
- return []
- entries = self._ensure_popular()
- self._anime_results.update({entry.title: entry for entry in entries if entry.title})
- return [entry.title for entry in entries if entry.title]
-
- def latest_episodes(self, page: int = 1) -> List[LatestEpisode]:
- if not self._requests_available:
- return []
- try:
- page = int(page or 1)
- except Exception:
- page = 1
- page = max(1, page)
-
- cached = self._latest_cache.get(page)
- if cached is not None:
- return list(cached)
-
- url = LATEST_EPISODES_URL
- if page > 1:
- url = f"{url}?page={page}"
-
- soup = _get_soup_simple(url)
- episodes = _extract_latest_episodes(soup)
- self._latest_cache[page] = list(episodes)
- return list(episodes)
-
- def _ensure_genres(self) -> Dict[str, List[SeriesResult]]:
- if self._genre_cache is not None:
- return {key: list(value) for key, value in self._genre_cache.items()}
- soup = _get_soup_simple(GENRES_URL)
- results: Dict[str, List[SeriesResult]] = {}
- genre_blocks = soup.select("#seriesContainer div.genre")
- if not genre_blocks:
- genre_blocks = soup.select("div.genre")
- for genre_block in genre_blocks:
- name_node = genre_block.select_one(".seriesGenreList h3")
- genre_name = (name_node.get_text(" ", strip=True) if name_node else "").strip()
- if not genre_name:
- continue
- entries: List[SeriesResult] = []
- seen: set[str] = set()
- for anchor in genre_block.select("ul li a[href]"):
- href = (anchor.get("href") or "").strip()
- if not href or "/staffel-" in href or "/episode-" in href:
- continue
- url = _absolute_url(href)
- if url:
- _log_parsed_url(url)
- title = (anchor.get_text(" ", strip=True) or "").strip()
- if not title:
- continue
- key = title.casefold().strip()
- if key in seen:
- continue
- seen.add(key)
- entries.append(SeriesResult(title=title, description="", url=url))
- if entries:
- results[genre_name] = entries
- self._genre_cache = {key: list(value) for key, value in results.items()}
- # Für spätere Auflösung (Seasons/Episoden) die Titel->URL Zuordnung auffüllen.
- for entries in results.values():
- for entry in entries:
- if not entry.title:
- continue
- if entry.title not in self._anime_results:
- self._anime_results[entry.title] = entry
- return {key: list(value) for key, value in results.items()}
-
- def genres(self) -> List[str]:
- if not self._requests_available:
- return []
- genres = list(self._ensure_genres().keys())
- return [g for g in genres if g]
-
- def titles_for_genre(self, genre: str) -> List[str]:
- genre = (genre or "").strip()
- if not genre or not self._requests_available:
- return []
- mapping = self._ensure_genres()
- entries = mapping.get(genre)
- if entries is None:
- wanted = genre.casefold()
- for key, value in mapping.items():
- if key.casefold() == wanted:
- entries = value
- break
- if not entries:
- return []
- # Zusätzlich sicherstellen, dass die Titel im Cache sind.
- self._anime_results.update({entry.title: entry for entry in entries if entry.title and entry.title not in self._anime_results})
- return [entry.title for entry in entries if entry.title]
-
- def _season_label(self, number: int) -> str:
- return f"Staffel {number}"
-
- def _parse_season_number(self, season_label: str) -> Optional[int]:
- match = re.search(DIGITS, season_label or "")
- return int(match.group(1)) if match else None
-
- def _episode_label(self, info: EpisodeInfo) -> str:
- title = (info.title or "").strip()
- if title:
- return f"Episode {info.number} - {title}"
- return f"Episode {info.number}"
-
- def _cache_episode_labels(self, title: str, season_label: str, season_info: SeasonInfo) -> None:
- cache_key = (title, season_label)
- self._episode_label_cache[cache_key] = {self._episode_label(info): info for info in season_info.episodes}
-
- def _lookup_episode(self, title: str, season_label: str, episode_label: str) -> Optional[EpisodeInfo]:
- cache_key = (title, season_label)
- cached = self._episode_label_cache.get(cache_key)
- if cached:
- return cached.get(episode_label)
- seasons = self._ensure_seasons(title)
- number = self._parse_season_number(season_label)
- if number is None:
- return None
- for season_info in seasons:
- if season_info.number == number:
- self._cache_episode_labels(title, season_label, season_info)
- return self._episode_label_cache.get(cache_key, {}).get(episode_label)
- return None
-
- async def search_titles(self, query: str) -> List[str]:
- query = (query or "").strip()
- if not query:
- self._anime_results.clear()
- self._season_cache.clear()
- self._episode_label_cache.clear()
- self._popular_cache = None
- return []
- if not self._requests_available:
- raise RuntimeError("AniworldPlugin kann ohne requests/bs4 nicht suchen.")
- try:
- results = search_animes(query)
- except Exception as exc: # pragma: no cover
- self._anime_results.clear()
- self._season_cache.clear()
- self._episode_label_cache.clear()
- raise RuntimeError(f"AniWorld-Suche fehlgeschlagen: {exc}") from exc
- self._anime_results = {result.title: result for result in results}
- self._season_cache.clear()
- self._episode_label_cache.clear()
- return [result.title for result in results]
-
- def _ensure_seasons(self, title: str) -> List[SeasonInfo]:
- if title in self._season_cache:
- return self._season_cache[title]
- anime = self._find_series_by_title(title)
- if not anime:
- return []
- seasons = scrape_anime_detail(anime.url)
- self._season_cache[title] = list(seasons)
- return list(seasons)
-
- def seasons_for(self, title: str) -> List[str]:
- seasons = self._ensure_seasons(title)
- return [self._season_label(season.number) for season in seasons if season.episodes]
-
- def episodes_for(self, title: str, season: str) -> List[str]:
- seasons = self._ensure_seasons(title)
- number = self._parse_season_number(season)
- if number is None:
- return []
- for season_info in seasons:
- if season_info.number == number:
- labels = [self._episode_label(info) for info in season_info.episodes]
- self._cache_episode_labels(title, season, season_info)
- return labels
- return []
-
- def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]:
- if not self._requests_available:
- raise RuntimeError("AniworldPlugin kann ohne requests/bs4 keine Stream-Links liefern.")
- episode_info = self._lookup_episode(title, season, episode)
- if not episode_info:
- return None
- link = fetch_episode_stream_link(episode_info.url, preferred_hosters=self._preferred_hosters)
- if link:
- _log_url(link, kind="FOUND")
- return link
-
- def available_hosters_for(self, title: str, season: str, episode: str) -> List[str]:
- if not self._requests_available:
- raise RuntimeError("AniworldPlugin kann ohne requests/bs4 keine Hoster laden.")
- cache_key = (title, season, episode)
- cached = self._hoster_cache.get(cache_key)
- if cached is not None:
- return list(cached)
- episode_info = self._lookup_episode(title, season, episode)
- if not episode_info:
- return []
- names = fetch_episode_hoster_names(episode_info.url)
- self._hoster_cache[cache_key] = list(names)
- return list(names)
-
- def available_hosters_for_url(self, episode_url: str) -> List[str]:
- if not self._requests_available:
- raise RuntimeError("AniworldPlugin kann ohne requests/bs4 keine Hoster laden.")
- normalized = _absolute_url(episode_url)
- cached = self._latest_hoster_cache.get(normalized)
- if cached is not None:
- return list(cached)
- names = fetch_episode_hoster_names(normalized)
- self._latest_hoster_cache[normalized] = list(names)
- return list(names)
-
- def stream_link_for_url(self, episode_url: str) -> Optional[str]:
- if not self._requests_available:
- raise RuntimeError("AniworldPlugin kann ohne requests/bs4 keine Stream-Links liefern.")
- normalized = _absolute_url(episode_url)
- link = fetch_episode_stream_link(normalized, preferred_hosters=self._preferred_hosters)
- if link:
- _log_url(link, kind="FOUND")
- return link
-
- def resolve_stream_link(self, link: str) -> Optional[str]:
- if not self._requests_available:
- raise RuntimeError("AniworldPlugin kann ohne requests/bs4 keine Stream-Links aufloesen.")
- resolved = resolve_redirect(link)
- if not resolved:
- return None
- try:
- from resolveurl_backend import resolve as resolve_with_resolveurl
- except Exception:
- resolve_with_resolveurl = None
- if callable(resolve_with_resolveurl):
- resolved_by_resolveurl = resolve_with_resolveurl(resolved)
- if resolved_by_resolveurl:
- _log_url("ResolveURL", kind="HOSTER_RESOLVER")
- _log_url(resolved_by_resolveurl, kind="MEDIA")
- return resolved_by_resolveurl
- _log_url(resolved, kind="FINAL")
- return resolved
-
- def set_preferred_hosters(self, hosters: List[str]) -> None:
- normalized = [hoster.strip().lower() for hoster in hosters if hoster.strip()]
- if normalized:
- self._preferred_hosters = normalized
-
- def reset_preferred_hosters(self) -> None:
- self._preferred_hosters = list(self._default_preferred_hosters)
-
-
-Plugin = AniworldPlugin
diff --git a/dist/plugin.video.viewit/plugins/einschalten_plugin.py b/dist/plugin.video.viewit/plugins/einschalten_plugin.py
deleted file mode 100644
index 7b4795a..0000000
--- a/dist/plugin.video.viewit/plugins/einschalten_plugin.py
+++ /dev/null
@@ -1,1052 +0,0 @@
-"""Einschalten Plugin.
-
-Optionales Debugging wie bei Serienstream:
-- URL-Logging
-- HTML-Dumps
-- On-Screen URL-Info
-"""
-
-from __future__ import annotations
-
-import json
-import re
-from dataclasses import dataclass
-from typing import Any, Dict, List, Optional, Set
-from urllib.parse import urlencode, urljoin, urlsplit
-
-try: # pragma: no cover - optional dependency (Kodi dependency)
- import requests
-except ImportError as exc: # pragma: no cover
- requests = None
- REQUESTS_AVAILABLE = False
- REQUESTS_IMPORT_ERROR = exc
-else:
- REQUESTS_AVAILABLE = True
- REQUESTS_IMPORT_ERROR = None
-
-try: # pragma: no cover - optional Kodi helpers
- import xbmcaddon # type: ignore[import-not-found]
-except ImportError: # pragma: no cover - allow running outside Kodi
- xbmcaddon = None
-
-from plugin_interface import BasisPlugin
-from plugin_helpers import dump_response_html, get_setting_bool, log_url, notify_url
-
-ADDON_ID = "plugin.video.viewit"
-SETTING_BASE_URL = "einschalten_base_url"
-SETTING_INDEX_PATH = "einschalten_index_path"
-SETTING_NEW_TITLES_PATH = "einschalten_new_titles_path"
-SETTING_SEARCH_PATH = "einschalten_search_path"
-SETTING_GENRES_PATH = "einschalten_genres_path"
-SETTING_ENABLE_PLAYBACK = "einschalten_enable_playback"
-SETTING_WATCH_PATH_TEMPLATE = "einschalten_watch_path_template"
-GLOBAL_SETTING_LOG_URLS = "debug_log_urls"
-GLOBAL_SETTING_DUMP_HTML = "debug_dump_html"
-GLOBAL_SETTING_SHOW_URL_INFO = "debug_show_url_info"
-
-DEFAULT_BASE_URL = ""
-DEFAULT_INDEX_PATH = "/"
-DEFAULT_NEW_TITLES_PATH = "/movies/new"
-DEFAULT_SEARCH_PATH = "/search"
-DEFAULT_GENRES_PATH = "/genres"
-DEFAULT_WATCH_PATH_TEMPLATE = "/api/movies/{id}/watch"
-
-HEADERS = {
- "User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)",
- "Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8",
- "Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
- "Connection": "keep-alive",
-}
-
-
-@dataclass(frozen=True)
-class MovieItem:
- id: int
- title: str
- release_date: str = ""
- poster_path: str = ""
- vote_average: float | None = None
- collection_id: int | None = None
-
-
-@dataclass(frozen=True)
-class MovieDetail:
- id: int
- title: str
- tagline: str = ""
- overview: str = ""
- release_date: str = ""
- runtime_minutes: int | None = None
- poster_path: str = ""
- backdrop_path: str = ""
- vote_average: float | None = None
- vote_count: int | None = None
- homepage: str = ""
- imdb_id: str = ""
- wikidata_id: str = ""
- genres: List[str] | None = None
-
-
-def _normalize_search_text(value: str) -> str:
- value = (value or "").casefold()
- value = re.sub(r"[^a-z0-9]+", " ", value)
- value = re.sub(r"\s+", " ", value).strip()
- return value
-
-
-def _matches_query(query: str, *, title: str) -> bool:
- normalized_query = _normalize_search_text(query)
- if not normalized_query:
- return False
- haystack = f" {_normalize_search_text(title)} "
- return f" {normalized_query} " in haystack
-
-
-def _filter_movies_by_title(query: str, movies: List[MovieItem]) -> List[MovieItem]:
- query = (query or "").strip()
- if not query:
- return []
- return [movie for movie in movies if _matches_query(query, title=movie.title)]
-
-
-def _get_setting_text(setting_id: str, *, default: str = "") -> str:
- if xbmcaddon is None:
- return default
- try:
- addon = xbmcaddon.Addon(ADDON_ID)
- getter = getattr(addon, "getSettingString", None)
- if getter is not None:
- return str(getter(setting_id) or "").strip()
- return str(addon.getSetting(setting_id) or "").strip()
- except Exception:
- return default
-
-
-def _get_setting_bool(setting_id: str, *, default: bool = False) -> bool:
- return get_setting_bool(ADDON_ID, setting_id, default=default)
-
-
-def _ensure_requests() -> None:
- if requests is None:
- raise RuntimeError(f"requests ist nicht verfuegbar: {REQUESTS_IMPORT_ERROR}")
-
-
-def _extract_ng_state_payload(html: str) -> Dict[str, Any]:
- """Extrahiert JSON aus ``."""
- html = html or ""
- # Regex ist hier ausreichend und vermeidet bs4-Abhängigkeit.
- match = re.search(
- r'',
- html,
- flags=re.IGNORECASE | re.DOTALL,
- )
- if not match:
- return {}
- raw = (match.group(1) or "").strip()
- if not raw:
- return {}
- try:
- data = json.loads(raw)
- except Exception:
- return {}
- return data if isinstance(data, dict) else {}
-
-
-def _notify_url(url: str) -> None:
- notify_url(ADDON_ID, heading="einschalten", url=url, enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO)
-
-
-def _log_url(url: str, *, kind: str = "VISIT") -> None:
- log_url(ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, log_filename="einschalten_urls.log", url=url, kind=kind)
-
-
-def _log_debug_line(message: str) -> None:
- try:
- log_url(ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, log_filename="einschalten_debug.log", url=message, kind="DEBUG")
- except Exception:
- pass
-
-
-def _log_titles(items: list[MovieItem], *, context: str) -> None:
- if not items:
- return
- try:
- log_url(
- ADDON_ID,
- enabled_setting_id=GLOBAL_SETTING_LOG_URLS,
- log_filename="einschalten_titles.log",
- url=f"{context}:count={len(items)}",
- kind="TITLE",
- )
- for item in items:
- log_url(
- ADDON_ID,
- enabled_setting_id=GLOBAL_SETTING_LOG_URLS,
- log_filename="einschalten_titles.log",
- url=f"{context}:id={item.id} title={item.title}",
- kind="TITLE",
- )
- except Exception:
- pass
-
-
-def _log_response_html(url: str, body: str) -> None:
- dump_response_html(
- ADDON_ID,
- enabled_setting_id=GLOBAL_SETTING_DUMP_HTML,
- url=url,
- body=body,
- filename_prefix="einschalten_response",
- )
-
-def _u_matches(value: Any, expected_path: str) -> bool:
- raw = (value or "").strip()
- if not raw:
- return False
- if raw == expected_path:
- return True
- try:
- if "://" in raw:
- path = urlsplit(raw).path or ""
- else:
- path = raw.split("?", 1)[0].split("#", 1)[0]
- if path == expected_path:
- return True
- except Exception:
- pass
- return raw.endswith(expected_path)
-
-
-def _parse_ng_state_movies(payload: Dict[str, Any]) -> List[MovieItem]:
- movies: List[MovieItem] = []
- for value in (payload or {}).values():
- if not isinstance(value, dict):
- continue
- # In ng-state payload, `u` (URL) is a sibling of `b` (body), not nested inside `b`.
- if not _u_matches(value.get("u"), "/api/movies"):
- continue
- block = value.get("b")
- if not isinstance(block, dict):
- continue
- data = block.get("data")
- if not isinstance(data, list):
- continue
- for item in data:
- if not isinstance(item, dict):
- continue
- try:
- movie_id = int(item.get("id"))
- except Exception:
- continue
- title = str(item.get("title") or "").strip()
- if not title:
- continue
- vote_average = item.get("voteAverage")
- try:
- vote_average_f = float(vote_average) if vote_average is not None else None
- except Exception:
- vote_average_f = None
- collection_id = item.get("collectionId")
- try:
- collection_id_i = int(collection_id) if collection_id is not None else None
- except Exception:
- collection_id_i = None
- movies.append(
- MovieItem(
- id=movie_id,
- title=title,
- release_date=str(item.get("releaseDate") or ""),
- poster_path=str(item.get("posterPath") or ""),
- vote_average=vote_average_f,
- collection_id=collection_id_i,
- )
- )
- return movies
-
-
-def _parse_ng_state_movies_with_pagination(payload: Dict[str, Any]) -> tuple[List[MovieItem], bool | None, int | None]:
- """Parses ng-state for `u: "/api/movies"` where `b` contains `{data:[...], pagination:{...}}`.
-
- Returns: (movies, has_more, current_page)
- """
-
- movies: List[MovieItem] = []
- has_more: bool | None = None
- current_page: int | None = None
-
- for value in (payload or {}).values():
- if not isinstance(value, dict):
- continue
- if not _u_matches(value.get("u"), "/api/movies"):
- continue
- block = value.get("b")
- if not isinstance(block, dict):
- continue
-
- pagination = block.get("pagination")
- if isinstance(pagination, dict):
- if "hasMore" in pagination:
- has_more = bool(pagination.get("hasMore") is True)
- try:
- current_page = int(pagination.get("currentPage")) if pagination.get("currentPage") is not None else None
- except Exception:
- current_page = None
-
- data = block.get("data")
- if not isinstance(data, list):
- continue
-
- for item in data:
- if not isinstance(item, dict):
- continue
- try:
- movie_id = int(item.get("id"))
- except Exception:
- continue
- title = str(item.get("title") or "").strip()
- if not title:
- continue
- vote_average = item.get("voteAverage")
- try:
- vote_average_f = float(vote_average) if vote_average is not None else None
- except Exception:
- vote_average_f = None
- collection_id = item.get("collectionId")
- try:
- collection_id_i = int(collection_id) if collection_id is not None else None
- except Exception:
- collection_id_i = None
- movies.append(
- MovieItem(
- id=movie_id,
- title=title,
- release_date=str(item.get("releaseDate") or ""),
- poster_path=str(item.get("posterPath") or ""),
- vote_average=vote_average_f,
- collection_id=collection_id_i,
- )
- )
-
- # Stop after first matching block (genre pages should only have one).
- break
-
- return movies, has_more, current_page
-
-
-def _parse_ng_state_search_results(payload: Dict[str, Any]) -> List[MovieItem]:
- movies: List[MovieItem] = []
- for value in (payload or {}).values():
- if not isinstance(value, dict):
- continue
- if not _u_matches(value.get("u"), "/api/search"):
- continue
- block = value.get("b")
- if not isinstance(block, dict):
- continue
- data = block.get("data")
- if not isinstance(data, list):
- continue
- for item in data:
- if not isinstance(item, dict):
- continue
- try:
- movie_id = int(item.get("id"))
- except Exception:
- continue
- title = str(item.get("title") or "").strip()
- if not title:
- continue
- vote_average = item.get("voteAverage")
- try:
- vote_average_f = float(vote_average) if vote_average is not None else None
- except Exception:
- vote_average_f = None
- collection_id = item.get("collectionId")
- try:
- collection_id_i = int(collection_id) if collection_id is not None else None
- except Exception:
- collection_id_i = None
- movies.append(
- MovieItem(
- id=movie_id,
- title=title,
- release_date=str(item.get("releaseDate") or ""),
- poster_path=str(item.get("posterPath") or ""),
- vote_average=vote_average_f,
- collection_id=collection_id_i,
- )
- )
- return movies
-
-
-def _parse_ng_state_movie_detail(payload: Dict[str, Any], *, movie_id: int) -> MovieDetail | None:
- movie_id = int(movie_id or 0)
- if movie_id <= 0:
- return None
- expected_u = f"/api/movies/{movie_id}"
- for value in (payload or {}).values():
- if not isinstance(value, dict):
- continue
- if not _u_matches(value.get("u"), expected_u):
- continue
- block = value.get("b")
- if not isinstance(block, dict):
- continue
- try:
- parsed_id = int(block.get("id"))
- except Exception:
- continue
- if parsed_id != movie_id:
- continue
- title = str(block.get("title") or "").strip()
- if not title:
- continue
- runtime = block.get("runtime")
- try:
- runtime_i = int(runtime) if runtime is not None else None
- except Exception:
- runtime_i = None
- vote_average = block.get("voteAverage")
- try:
- vote_average_f = float(vote_average) if vote_average is not None else None
- except Exception:
- vote_average_f = None
- vote_count = block.get("voteCount")
- try:
- vote_count_i = int(vote_count) if vote_count is not None else None
- except Exception:
- vote_count_i = None
- genres_raw = block.get("genres")
- genres: List[str] | None = None
- if isinstance(genres_raw, list):
- names: List[str] = []
- for g in genres_raw:
- if isinstance(g, dict):
- name = str(g.get("name") or "").strip()
- if name:
- names.append(name)
- genres = names
- return MovieDetail(
- id=movie_id,
- title=title,
- tagline=str(block.get("tagline") or "").strip(),
- overview=str(block.get("overview") or "").strip(),
- release_date=str(block.get("releaseDate") or "").strip(),
- runtime_minutes=runtime_i,
- poster_path=str(block.get("posterPath") or "").strip(),
- backdrop_path=str(block.get("backdropPath") or "").strip(),
- vote_average=vote_average_f,
- vote_count=vote_count_i,
- homepage=str(block.get("homepage") or "").strip(),
- imdb_id=str(block.get("imdbId") or "").strip(),
- wikidata_id=str(block.get("wikidataId") or "").strip(),
- genres=genres,
- )
- return None
-
-
-def _parse_ng_state_genres(payload: Dict[str, Any]) -> Dict[str, int]:
- """Parses ng-state for `u: "/api/genres"` where `b` is a list of {id,name}."""
- genres: Dict[str, int] = {}
- for value in (payload or {}).values():
- if not isinstance(value, dict):
- continue
- if not _u_matches(value.get("u"), "/api/genres"):
- continue
- block = value.get("b")
- if not isinstance(block, list):
- continue
- for item in block:
- if not isinstance(item, dict):
- continue
- name = str(item.get("name") or "").strip()
- if not name:
- continue
- try:
- gid = int(item.get("id"))
- except Exception:
- continue
- if gid > 0:
- genres[name] = gid
- return genres
-
-
-class EinschaltenPlugin(BasisPlugin):
- """Metadata-Plugin für eine autorisierte Quelle."""
-
- name = "einschalten"
-
- def __init__(self) -> None:
- self.is_available = REQUESTS_AVAILABLE
- self.unavailable_reason = None if REQUESTS_AVAILABLE else f"requests fehlt: {REQUESTS_IMPORT_ERROR}"
- self._session = None
- self._id_by_title: Dict[str, int] = {}
- self._detail_html_by_id: Dict[int, str] = {}
- self._detail_by_id: Dict[int, MovieDetail] = {}
- self._genre_id_by_name: Dict[str, int] = {}
- self._genre_has_more_by_id_page: Dict[tuple[int, int], bool] = {}
- self._new_titles_has_more_by_page: Dict[int, bool] = {}
-
- def _get_session(self):
- _ensure_requests()
- if self._session is None:
- self._session = requests.Session()
- return self._session
-
- def _get_base_url(self) -> str:
- base = _get_setting_text(SETTING_BASE_URL, default=DEFAULT_BASE_URL).strip()
- return base.rstrip("/")
-
- def _index_url(self) -> str:
- base = self._get_base_url()
- if not base:
- return ""
- path = _get_setting_text(SETTING_INDEX_PATH, default=DEFAULT_INDEX_PATH).strip() or "/"
- return urljoin(base + "/", path.lstrip("/"))
-
- def _new_titles_url(self) -> str:
- base = self._get_base_url()
- if not base:
- return ""
- path = _get_setting_text(SETTING_NEW_TITLES_PATH, default=DEFAULT_NEW_TITLES_PATH).strip() or "/movies/new"
- return urljoin(base + "/", path.lstrip("/"))
-
- def _genres_url(self) -> str:
- base = self._get_base_url()
- if not base:
- return ""
- path = _get_setting_text(SETTING_GENRES_PATH, default=DEFAULT_GENRES_PATH).strip() or "/genres"
- return urljoin(base + "/", path.lstrip("/"))
-
- def _api_genres_url(self) -> str:
- base = self._get_base_url()
- if not base:
- return ""
- return urljoin(base + "/", "api/genres")
-
- def _search_url(self, query: str) -> str:
- base = self._get_base_url()
- if not base:
- return ""
- path = _get_setting_text(SETTING_SEARCH_PATH, default=DEFAULT_SEARCH_PATH).strip() or "/search"
- url = urljoin(base + "/", path.lstrip("/"))
- return f"{url}?{urlencode({'query': query})}"
-
- def _api_movies_url(self, *, with_genres: int, page: int = 1) -> str:
- base = self._get_base_url()
- if not base:
- return ""
- params: Dict[str, str] = {"withGenres": str(int(with_genres))}
- if page and int(page) > 1:
- params["page"] = str(int(page))
- return urljoin(base + "/", "api/movies") + f"?{urlencode(params)}"
-
- def _genre_page_url(self, *, genre_id: int, page: int = 1) -> str:
- """Genre title pages are rendered server-side and embed the movie list in ng-state.
-
- Example:
- - `/genres/` contains ng-state with `u: "/api/movies"` and `b.data` + `b.pagination`.
- """
-
- base = self._get_base_url()
- if not base:
- return ""
- genre_root = self._genres_url().rstrip("/")
- if not genre_root:
- return ""
- page = max(1, int(page or 1))
- url = urljoin(genre_root + "/", str(int(genre_id)))
- if page > 1:
- url = f"{url}?{urlencode({'page': str(page)})}"
- return url
-
- def _movie_detail_url(self, movie_id: int) -> str:
- base = self._get_base_url()
- if not base:
- return ""
- return urljoin(base + "/", f"movies/{int(movie_id)}")
-
- def _watch_url(self, movie_id: int) -> str:
- base = self._get_base_url()
- if not base:
- return ""
- template = _get_setting_text(SETTING_WATCH_PATH_TEMPLATE, default=DEFAULT_WATCH_PATH_TEMPLATE).strip()
- if not template:
- template = DEFAULT_WATCH_PATH_TEMPLATE
- try:
- path = template.format(id=int(movie_id))
- except Exception:
- path = DEFAULT_WATCH_PATH_TEMPLATE.format(id=int(movie_id))
- return urljoin(base + "/", path.lstrip("/"))
-
- def _ensure_title_id(self, title: str) -> int | None:
- title = (title or "").strip()
- if not title:
- return None
- cached = self._id_by_title.get(title)
- if isinstance(cached, int) and cached > 0:
- return cached
- # Fallback: scan index ng-state again to rebuild mapping.
- for movie in self._load_movies():
- if movie.title == title:
- self._id_by_title[title] = movie.id
- return movie.id
- # Kodi startet das Plugin pro Navigation neu -> RAM-Cache geht verloren.
- # Für Titel, die nicht auf der Index-Seite sind (z.B. /movies/new), lösen wir die ID
- # über die Suchseite auf, die ebenfalls `id` + `title` im ng-state liefert.
- try:
- normalized = title.casefold().strip()
- for movie in self._fetch_search_movies(title):
- if (movie.title or "").casefold().strip() == normalized:
- self._id_by_title[title] = movie.id
- return movie.id
- except Exception:
- pass
- return None
-
- def _fetch_movie_detail(self, movie_id: int) -> str:
- movie_id = int(movie_id or 0)
- if movie_id <= 0:
- return ""
- cached = self._detail_html_by_id.get(movie_id)
- if isinstance(cached, str) and cached:
- return cached
- url = self._movie_detail_url(movie_id)
- if not url:
- return ""
- try:
- _log_url(url, kind="GET")
- _notify_url(url)
- sess = self._get_session()
- resp = sess.get(url, headers=HEADERS, timeout=20)
- resp.raise_for_status()
- _log_url(resp.url or url, kind="OK")
- _log_response_html(resp.url or url, resp.text)
- self._detail_html_by_id[movie_id] = resp.text or ""
- return resp.text or ""
- except Exception:
- return ""
-
- def _fetch_watch_payload(self, movie_id: int) -> dict[str, object]:
- movie_id = int(movie_id or 0)
- if movie_id <= 0:
- return {}
- url = self._watch_url(movie_id)
- if not url:
- return {}
- try:
- _log_url(url, kind="GET")
- _notify_url(url)
- sess = self._get_session()
- resp = sess.get(url, headers=HEADERS, timeout=20)
- resp.raise_for_status()
- _log_url(resp.url or url, kind="OK")
- # Some backends may return JSON with a JSON content-type; for debugging we still dump text.
- _log_response_html(resp.url or url, resp.text)
- data = resp.json()
- return dict(data) if isinstance(data, dict) else {}
- except Exception:
- return {}
-
- def _watch_stream_url(self, movie_id: int) -> str:
- payload = self._fetch_watch_payload(movie_id)
- stream_url = payload.get("streamUrl")
- return str(stream_url).strip() if isinstance(stream_url, str) and stream_url.strip() else ""
-
- def metadata_for(self, title: str) -> tuple[dict[str, str], dict[str, str], list[object] | None]:
- """Optional hook for the UI layer (default.py) to attach metadata/art without TMDB."""
- title = (title or "").strip()
- movie_id = self._ensure_title_id(title)
- if movie_id is None:
- return {}, {}, None
-
- detail = self._detail_by_id.get(movie_id)
- if detail is None:
- html = self._fetch_movie_detail(movie_id)
- payload = _extract_ng_state_payload(html)
- parsed = _parse_ng_state_movie_detail(payload, movie_id=movie_id)
- if parsed is not None:
- self._detail_by_id[movie_id] = parsed
- detail = parsed
-
- info: dict[str, str] = {"mediatype": "movie", "title": title}
- art: dict[str, str] = {}
- if detail is None:
- return info, art, None
-
- if detail.overview:
- info["plot"] = detail.overview
- if detail.tagline:
- info["tagline"] = detail.tagline
- if detail.release_date:
- info["premiered"] = detail.release_date
- if len(detail.release_date) >= 4 and detail.release_date[:4].isdigit():
- info["year"] = detail.release_date[:4]
- if detail.runtime_minutes is not None and detail.runtime_minutes > 0:
- info["duration"] = str(int(detail.runtime_minutes) * 60)
- if detail.vote_average is not None:
- info["rating"] = str(detail.vote_average)
- if detail.vote_count is not None:
- info["votes"] = str(detail.vote_count)
- if detail.genres:
- info["genre"] = " / ".join(detail.genres)
-
- base = self._get_base_url()
- if base:
- if detail.poster_path:
- poster = urljoin(base + "/", f"api/image/poster/{detail.poster_path.lstrip('/')}")
- art.update({"thumb": poster, "poster": poster})
- if detail.backdrop_path:
- backdrop = urljoin(base + "/", f"api/image/backdrop/{detail.backdrop_path.lstrip('/')}")
- art.setdefault("fanart", backdrop)
- art.setdefault("landscape", backdrop)
-
- return info, art, None
-
- def _fetch_index_movies(self) -> List[MovieItem]:
- url = self._index_url()
- if not url:
- return []
- try:
- _log_url(url, kind="GET")
- _notify_url(url)
- sess = self._get_session()
- resp = sess.get(url, headers=HEADERS, timeout=20)
- resp.raise_for_status()
- _log_url(resp.url or url, kind="OK")
- _log_response_html(resp.url or url, resp.text)
- payload = _extract_ng_state_payload(resp.text)
- return _parse_ng_state_movies(payload)
- except Exception:
- return []
-
- def _fetch_new_titles_movies(self) -> List[MovieItem]:
- # "Neue Filme" lives at `/movies/new` and embeds the list in ng-state (`u: "/api/movies"`).
- url = self._new_titles_url()
- if not url:
- return []
- try:
- _log_url(url, kind="GET")
- _notify_url(url)
- sess = self._get_session()
- resp = sess.get(url, headers=HEADERS, timeout=20)
- resp.raise_for_status()
- _log_url(resp.url or url, kind="OK")
- _log_response_html(resp.url or url, resp.text)
- payload = _extract_ng_state_payload(resp.text)
- movies = _parse_ng_state_movies(payload)
- _log_debug_line(f"parse_ng_state_movies:count={len(movies)}")
- if movies:
- _log_titles(movies, context="new_titles")
- return movies
- return []
- except Exception:
- return []
-
- def _fetch_new_titles_movies_page(self, page: int) -> List[MovieItem]:
- page = max(1, int(page or 1))
- url = self._new_titles_url()
- if not url:
- return []
- if page > 1:
- url = f"{url}?{urlencode({'page': str(page)})}"
- try:
- _log_url(url, kind="GET")
- _notify_url(url)
- sess = self._get_session()
- resp = sess.get(url, headers=HEADERS, timeout=20)
- resp.raise_for_status()
- _log_url(resp.url or url, kind="OK")
- _log_response_html(resp.url or url, resp.text)
- payload = _extract_ng_state_payload(resp.text)
- movies, has_more, current_page = _parse_ng_state_movies_with_pagination(payload)
- _log_debug_line(f"parse_ng_state_movies_page:page={page} count={len(movies)}")
- if has_more is not None:
- self._new_titles_has_more_by_page[page] = bool(has_more)
- elif current_page is not None and int(current_page) != page:
- self._new_titles_has_more_by_page[page] = False
- if movies:
- _log_titles(movies, context=f"new_titles_page={page}")
- return movies
- self._new_titles_has_more_by_page[page] = False
- return []
- except Exception:
- return []
-
- def new_titles_page(self, page: int) -> List[str]:
- """Paged variant: returns titles for `/movies/new?page=`."""
- if not REQUESTS_AVAILABLE:
- return []
- if not self._get_base_url():
- return []
- page = max(1, int(page or 1))
- movies = self._fetch_new_titles_movies_page(page)
- titles: List[str] = []
- seen: set[str] = set()
- for movie in movies:
- if movie.title in seen:
- continue
- seen.add(movie.title)
- self._id_by_title[movie.title] = movie.id
- titles.append(movie.title)
- return titles
-
- def new_titles_has_more(self, page: int) -> bool:
- """Tells the UI whether `/movies/new` has a next page after `page`."""
- page = max(1, int(page or 1))
- cached = self._new_titles_has_more_by_page.get(page)
- if cached is not None:
- return bool(cached)
- # Load page to fill cache.
- _ = self._fetch_new_titles_movies_page(page)
- return bool(self._new_titles_has_more_by_page.get(page, False))
-
- def _fetch_search_movies(self, query: str) -> List[MovieItem]:
- query = (query or "").strip()
- if not query:
- return []
-
- # Parse ng-state from /search page HTML.
- url = self._search_url(query)
- if not url:
- return []
- try:
- _log_url(url, kind="GET")
- _notify_url(url)
- sess = self._get_session()
- resp = sess.get(url, headers=HEADERS, timeout=20)
- resp.raise_for_status()
- _log_url(resp.url or url, kind="OK")
- _log_response_html(resp.url or url, resp.text)
- payload = _extract_ng_state_payload(resp.text)
- results = _parse_ng_state_search_results(payload)
- return _filter_movies_by_title(query, results)
- except Exception:
- return []
-
- def _load_movies(self) -> List[MovieItem]:
- return self._fetch_index_movies()
-
- def _ensure_genre_index(self) -> None:
- if self._genre_id_by_name:
- return
- # Prefer direct JSON API (simpler): GET /api/genres -> [{"id":..,"name":..}, ...]
- api_url = self._api_genres_url()
- if api_url:
- try:
- _log_url(api_url, kind="GET")
- _notify_url(api_url)
- sess = self._get_session()
- resp = sess.get(api_url, headers=HEADERS, timeout=20)
- resp.raise_for_status()
- _log_url(resp.url or api_url, kind="OK")
- payload = resp.json()
- if isinstance(payload, list):
- parsed: Dict[str, int] = {}
- for item in payload:
- if not isinstance(item, dict):
- continue
- name = str(item.get("name") or "").strip()
- if not name:
- continue
- try:
- gid = int(item.get("id"))
- except Exception:
- continue
- if gid > 0:
- parsed[name] = gid
- if parsed:
- self._genre_id_by_name.clear()
- self._genre_id_by_name.update(parsed)
- return
- except Exception:
- pass
-
- # Fallback: parse ng-state from HTML /genres page.
- url = self._genres_url()
- if not url:
- return
- try:
- _log_url(url, kind="GET")
- _notify_url(url)
- sess = self._get_session()
- resp = sess.get(url, headers=HEADERS, timeout=20)
- resp.raise_for_status()
- _log_url(resp.url or url, kind="OK")
- _log_response_html(resp.url or url, resp.text)
- payload = _extract_ng_state_payload(resp.text)
- parsed = _parse_ng_state_genres(payload)
- if parsed:
- self._genre_id_by_name.clear()
- self._genre_id_by_name.update(parsed)
- except Exception:
- return
-
- async def search_titles(self, query: str) -> List[str]:
- if not REQUESTS_AVAILABLE:
- return []
- query = (query or "").strip()
- if not query:
- return []
- if not self._get_base_url():
- return []
-
- movies = self._fetch_search_movies(query)
- if not movies:
- movies = _filter_movies_by_title(query, self._load_movies())
- titles: List[str] = []
- seen: set[str] = set()
- for movie in movies:
- if movie.title in seen:
- continue
- seen.add(movie.title)
- self._id_by_title[movie.title] = movie.id
- titles.append(movie.title)
- titles.sort(key=lambda value: value.casefold())
- return titles
-
- def genres(self) -> List[str]:
- if not REQUESTS_AVAILABLE:
- return []
- if not self._get_base_url():
- return []
- self._ensure_genre_index()
- return sorted(self._genre_id_by_name.keys(), key=lambda value: value.casefold())
-
- def titles_for_genre(self, genre: str) -> List[str]:
- # Backwards compatible (first page only); paging handled via titles_for_genre_page().
- titles = self.titles_for_genre_page(genre, 1)
- titles.sort(key=lambda value: value.casefold())
- return titles
-
- def titles_for_genre_page(self, genre: str, page: int) -> List[str]:
- if not REQUESTS_AVAILABLE:
- return []
- genre = (genre or "").strip()
- if not genre:
- return []
- if not self._get_base_url():
- return []
- self._ensure_genre_index()
- genre_id = self._genre_id_by_name.get(genre)
- if not genre_id:
- return []
- # Do NOT use `/api/movies?withGenres=...` directly: on some deployments it returns
- # a mismatched/unfiltered dataset. Instead parse the server-rendered genre page
- # `/genres/` which embeds the correct data in ng-state.
- url = self._genre_page_url(genre_id=int(genre_id), page=max(1, int(page or 1)))
- if not url:
- return []
- try:
- _log_url(url, kind="GET")
- _notify_url(url)
- sess = self._get_session()
- resp = sess.get(url, headers=HEADERS, timeout=20)
- resp.raise_for_status()
- _log_url(resp.url or url, kind="OK")
- _log_response_html(resp.url or url, resp.text)
- payload = _extract_ng_state_payload(resp.text)
- except Exception:
- return []
- if not isinstance(payload, dict):
- return []
-
- movies, has_more, current_page = _parse_ng_state_movies_with_pagination(payload)
- page = max(1, int(page or 1))
- if has_more is not None:
- self._genre_has_more_by_id_page[(int(genre_id), page)] = bool(has_more)
- elif current_page is not None and int(current_page) != page:
- # Defensive: if the page param wasn't honored, avoid showing "next".
- self._genre_has_more_by_id_page[(int(genre_id), page)] = False
-
- titles: List[str] = []
- seen: set[str] = set()
- for movie in movies:
- title = (movie.title or "").strip()
- if not title or title in seen:
- continue
- seen.add(title)
- if movie.id > 0:
- self._id_by_title[title] = int(movie.id)
- titles.append(title)
- return titles
-
- def genre_has_more(self, genre: str, page: int) -> bool:
- """Optional: tells the UI whether a genre has more pages after `page`."""
- genre = (genre or "").strip()
- if not genre:
- return False
- self._ensure_genre_index()
- genre_id = self._genre_id_by_name.get(genre)
- if not genre_id:
- return False
- page = max(1, int(page or 1))
- cached = self._genre_has_more_by_id_page.get((int(genre_id), page))
- if cached is not None:
- return bool(cached)
- # If the page wasn't loaded yet, load it (fills the cache) and then report.
- _ = self.titles_for_genre_page(genre, page)
- return bool(self._genre_has_more_by_id_page.get((int(genre_id), page), False))
-
- def seasons_for(self, title: str) -> List[str]:
- # Beim Öffnen eines Titels: Detailseite anhand der ID abrufen (HTML) und cachen.
- title = (title or "").strip()
- if not title:
- return []
- movie_id = self._ensure_title_id(title)
- if movie_id is not None:
- self._fetch_movie_detail(movie_id)
- if _get_setting_bool(SETTING_ENABLE_PLAYBACK, default=False):
- # Playback: expose a single "Stream" folder (inside: 1 playable item = Filmtitel).
- return ["Stream"]
- return ["Details"]
-
- def episodes_for(self, title: str, season: str) -> List[str]:
- season = (season or "").strip()
- if season.casefold() == "stream" and _get_setting_bool(SETTING_ENABLE_PLAYBACK, default=False):
- title = (title or "").strip()
- return [title] if title else []
- return []
-
- def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]:
- if not _get_setting_bool(SETTING_ENABLE_PLAYBACK, default=False):
- return None
- title = (title or "").strip()
- season = (season or "").strip()
- episode = (episode or "").strip()
- # Backwards compatible:
- # - old: Film / Stream
- # - new: Stream /
- if not title:
- return None
- if season.casefold() == "film" and episode.casefold() == "stream":
- pass
- elif season.casefold() == "stream" and (episode == title or episode.casefold() == "stream"):
- pass
- else:
- return None
- movie_id = self._ensure_title_id(title)
- if movie_id is None:
- return None
- stream_url = self._watch_stream_url(movie_id)
- return stream_url or None
-
- def resolve_stream_link(self, link: str) -> Optional[str]:
- try:
- from resolveurl_backend import resolve as resolve_with_resolveurl
- except Exception:
- resolve_with_resolveurl = None
- if callable(resolve_with_resolveurl):
- return resolve_with_resolveurl(link) or link
- return link
-
- def capabilities(self) -> Set[str]:
- return {"new_titles", "genres"}
-
- def new_titles(self) -> List[str]:
- if not REQUESTS_AVAILABLE:
- return []
- if not self._get_base_url():
- return []
- # Backwards compatible: first page only. UI uses paging via `new_titles_page`.
- return self.new_titles_page(1)
diff --git a/dist/plugin.video.viewit/plugins/serienstream_plugin.py b/dist/plugin.video.viewit/plugins/serienstream_plugin.py
deleted file mode 100644
index 8f139dc..0000000
--- a/dist/plugin.video.viewit/plugins/serienstream_plugin.py
+++ /dev/null
@@ -1,966 +0,0 @@
-"""Serienstream (s.to) Integration als Downloader-Plugin.
-
-Hinweise:
-- Diese Integration nutzt optional `requests` + `beautifulsoup4` (bs4).
-- In Kodi koennen zusaetzliche Debug-Funktionen ueber Addon-Settings aktiviert werden
- (URL-Logging, HTML-Dumps, Benachrichtigungen).
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from datetime import datetime
-import hashlib
-import os
-import re
-from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, TypeAlias
-
-try: # pragma: no cover - optional dependency
- import requests
- from bs4 import BeautifulSoup # type: ignore[import-not-found]
-except ImportError as exc: # pragma: no cover - optional dependency
- requests = None
- BeautifulSoup = None
- REQUESTS_AVAILABLE = False
- REQUESTS_IMPORT_ERROR = exc
-else:
- REQUESTS_AVAILABLE = True
- REQUESTS_IMPORT_ERROR = None
-
-try: # pragma: no cover - optional Kodi helpers
- import xbmcaddon # type: ignore[import-not-found]
- import xbmcvfs # type: ignore[import-not-found]
- import xbmcgui # type: ignore[import-not-found]
-except ImportError: # pragma: no cover - allow running outside Kodi
- xbmcaddon = None
- xbmcvfs = None
- xbmcgui = None
-
-from plugin_interface import BasisPlugin
-from plugin_helpers import dump_response_html, get_setting_bool, log_url, notify_url
-from http_session_pool import get_requests_session
-from regex_patterns import SEASON_EPISODE_TAG, SEASON_EPISODE_URL
-
-if TYPE_CHECKING: # pragma: no cover
- from requests import Session as RequestsSession
- from bs4 import BeautifulSoup as BeautifulSoupT # type: ignore[import-not-found]
-else: # pragma: no cover
- RequestsSession: TypeAlias = Any
- BeautifulSoupT: TypeAlias = Any
-
-
-BASE_URL = "https://s.to"
-SERIES_BASE_URL = f"{BASE_URL}/serie/stream"
-POPULAR_SERIES_URL = f"{BASE_URL}/beliebte-serien"
-LATEST_EPISODES_URL = f"{BASE_URL}"
-DEFAULT_PREFERRED_HOSTERS = ["voe"]
-DEFAULT_TIMEOUT = 20
-ADDON_ID = "plugin.video.viewit"
-GLOBAL_SETTING_LOG_URLS = "debug_log_urls"
-GLOBAL_SETTING_DUMP_HTML = "debug_dump_html"
-GLOBAL_SETTING_SHOW_URL_INFO = "debug_show_url_info"
-HEADERS = {
- "User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)",
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
- "Connection": "keep-alive",
-}
-
-
-@dataclass
-class SeriesResult:
- title: str
- description: str
- url: str
-
-
-@dataclass
-class EpisodeInfo:
- number: int
- title: str
- original_title: str
- url: str
- season_label: str = ""
- languages: List[str] = field(default_factory=list)
- hosters: List[str] = field(default_factory=list)
-
-
-@dataclass
-class LatestEpisode:
- series_title: str
- season: int
- episode: int
- url: str
- airdate: str
-
-
-@dataclass
-class SeasonInfo:
- number: int
- url: str
- episodes: List[EpisodeInfo]
-
-
-def _absolute_url(href: str) -> str:
- return f"{BASE_URL}{href}" if href.startswith("/") else href
-
-
-def _normalize_series_url(identifier: str) -> str:
- if identifier.startswith("http://") or identifier.startswith("https://"):
- return identifier.rstrip("/")
- slug = identifier.strip("/")
- return f"{SERIES_BASE_URL}/{slug}"
-
-
-def _series_root_url(url: str) -> str:
- """Normalisiert eine Serien-URL auf die Root-URL (ohne /staffel-x oder /episode-x)."""
- normalized = (url or "").strip().rstrip("/")
- normalized = re.sub(r"/staffel-\d+(?:/.*)?$", "", normalized)
- normalized = re.sub(r"/episode-\d+(?:/.*)?$", "", normalized)
- return normalized.rstrip("/")
-
-
-def _log_visit(url: str) -> None:
- _log_url(url, kind="VISIT")
- _notify_url(url)
- if xbmcaddon is None:
- print(f"Visiting: {url}")
-
-
-def _normalize_text(value: str) -> str:
- """Legacy normalization (kept for backwards compatibility)."""
- value = value.casefold()
- value = re.sub(r"[^a-z0-9]+", "", value)
- return value
-
-
-def _normalize_search_text(value: str) -> str:
- """Normalisiert Text für die Suche ohne Wortgrenzen zu "verschmelzen".
-
- Wichtig: Wir ersetzen Nicht-Alphanumerisches durch Leerzeichen, statt es zu entfernen.
- Dadurch entstehen keine künstlichen Treffer über Wortgrenzen hinweg (z.B. "an" + "na" -> "anna").
- """
-
- value = (value or "").casefold()
- value = re.sub(r"[^a-z0-9]+", " ", value)
- value = re.sub(r"\s+", " ", value).strip()
- return value
-
-
-def _get_setting_bool(setting_id: str, *, default: bool = False) -> bool:
- return get_setting_bool(ADDON_ID, setting_id, default=default)
-
-
-def _notify_url(url: str) -> None:
- notify_url(ADDON_ID, heading="Serienstream", url=url, enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO)
-
-
-def _log_url(url: str, *, kind: str = "VISIT") -> None:
- log_url(ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, log_filename="serienstream_urls.log", url=url, kind=kind)
-
-
-def _log_parsed_url(url: str) -> None:
- _log_url(url, kind="PARSE")
-
-
-def _log_response_html(url: str, body: str) -> None:
- dump_response_html(
- ADDON_ID,
- enabled_setting_id=GLOBAL_SETTING_DUMP_HTML,
- url=url,
- body=body,
- filename_prefix="s_to_response",
- )
-
-
-def _ensure_requests() -> None:
- if requests is None or BeautifulSoup is None:
- raise RuntimeError("requests/bs4 sind nicht verfuegbar.")
-
-
-def _looks_like_cloudflare_challenge(body: str) -> bool:
- lower = body.lower()
- markers = (
- "cf-browser-verification",
- "cf-challenge",
- "cf_chl",
- "challenge-platform",
- "attention required! | cloudflare",
- "just a moment...",
- "cloudflare ray id",
- )
- return any(marker in lower for marker in markers)
-
-
-def _get_soup(url: str, *, session: Optional[RequestsSession] = None) -> BeautifulSoupT:
- _ensure_requests()
- _log_visit(url)
- sess = session or get_requests_session("serienstream", headers=HEADERS)
- response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
- response.raise_for_status()
- if response.url and response.url != url:
- _log_url(response.url, kind="REDIRECT")
- _log_response_html(url, response.text)
- if _looks_like_cloudflare_challenge(response.text):
- raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.")
- return BeautifulSoup(response.text, "html.parser")
-
-
-def _get_soup_simple(url: str) -> BeautifulSoupT:
- _ensure_requests()
- _log_visit(url)
- sess = get_requests_session("serienstream", headers=HEADERS)
- response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
- response.raise_for_status()
- if response.url and response.url != url:
- _log_url(response.url, kind="REDIRECT")
- _log_response_html(url, response.text)
- if _looks_like_cloudflare_challenge(response.text):
- raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.")
- return BeautifulSoup(response.text, "html.parser")
-
-
-def search_series(query: str) -> List[SeriesResult]:
- """Sucht Serien im (/serien)-Katalog (Genre-liste) nach Titel/Alt-Titel."""
- _ensure_requests()
- normalized_query = _normalize_search_text(query)
- if not normalized_query:
- return []
- # Direkter Abruf wie in fetch_serien.py.
- catalog_url = f"{BASE_URL}/serien?by=genre"
- soup = _get_soup_simple(catalog_url)
- results: List[SeriesResult] = []
- for series in parse_series_catalog(soup).values():
- for entry in series:
- haystack = _normalize_search_text(entry.title)
- if entry.title and normalized_query in haystack:
- results.append(entry)
- return results
-
-
-def parse_series_catalog(soup: BeautifulSoupT) -> Dict[str, List[SeriesResult]]:
- """Parst die Serien-Übersicht (/serien) und liefert Genre -> Serienliste."""
- catalog: Dict[str, List[SeriesResult]] = {}
-
- # Neues Layout (Stand: 2026-01): Gruppen-Header + Liste.
- # - Header: `div.background-1 ...` mit `h3`
- # - Einträge: `ul.series-list` -> `li.series-item[data-search]` -> `a[href]`
- for header in soup.select("div.background-1 h3"):
- group = (header.get_text(strip=True) or "").strip()
- if not group:
- continue
- list_node = header.parent.find_next_sibling("ul", class_="series-list")
- if not list_node:
- continue
- series: List[SeriesResult] = []
- for item in list_node.select("li.series-item"):
- anchor = item.find("a", href=True)
- if not anchor:
- continue
- href = (anchor.get("href") or "").strip()
- url = _absolute_url(href)
- if url:
- _log_parsed_url(url)
- if ("/serie/" not in url) or "/staffel-" in url or "/episode-" in url:
- continue
- title = (anchor.get_text(" ", strip=True) or "").strip()
- description = (item.get("data-search") or "").strip()
- if title:
- series.append(SeriesResult(title=title, description=description, url=url))
- if series:
- catalog[group] = series
-
- return catalog
-
-
-def _extract_season_links(soup: BeautifulSoupT) -> List[Tuple[int, str]]:
- season_links: List[Tuple[int, str]] = []
- seen_numbers: set[int] = set()
- anchors = soup.select("ul.nav.list-items-nav a[data-season-pill][href]")
- for anchor in anchors:
- href = anchor.get("href") or ""
- if "/episode-" in href:
- continue
- data_number = (anchor.get("data-season-pill") or "").strip()
- match = re.search(r"/staffel-(\d+)", href)
- if match:
- number = int(match.group(1))
- elif data_number.isdigit():
- number = int(data_number)
- else:
- label = anchor.get_text(strip=True)
- if not label.isdigit():
- continue
- number = int(label)
- if number in seen_numbers:
- continue
- seen_numbers.add(number)
- season_url = _absolute_url(href)
- if season_url:
- _log_parsed_url(season_url)
- season_links.append((number, season_url))
- season_links.sort(key=lambda item: item[0])
- return season_links
-
-
-def _extract_number_of_seasons(soup: BeautifulSoupT) -> Optional[int]:
- tag = soup.select_one('meta[itemprop="numberOfSeasons"]')
- if not tag:
- return None
- content = (tag.get("content") or "").strip()
- if not content.isdigit():
- return None
- count = int(content)
- return count if count > 0 else None
-
-
-def _extract_canonical_url(soup: BeautifulSoupT, fallback: str) -> str:
- canonical = soup.select_one('link[rel="canonical"][href]')
- href = (canonical.get("href") if canonical else "") or ""
- href = href.strip()
- if href.startswith("http://") or href.startswith("https://"):
- return href.rstrip("/")
- return fallback.rstrip("/")
-
-
-def _extract_episodes(soup: BeautifulSoupT) -> List[EpisodeInfo]:
- episodes: List[EpisodeInfo] = []
- season_label = ""
- season_header = soup.select_one("section.episode-section h2") or soup.select_one("h2.h3")
- if season_header:
- season_label = (season_header.get_text(" ", strip=True) or "").strip()
-
- language_map = {
- "german": "DE",
- "english": "EN",
- "japanese": "JP",
- "turkish": "TR",
- "spanish": "ES",
- "italian": "IT",
- "french": "FR",
- "korean": "KO",
- "russian": "RU",
- "polish": "PL",
- "portuguese": "PT",
- "chinese": "ZH",
- "arabic": "AR",
- "thai": "TH",
- }
- # Neues Layout (Stand: 2026-01): Episoden-Tabelle mit Zeilen und onclick-URL.
- rows = soup.select("table.episode-table tbody tr.episode-row")
- for index, row in enumerate(rows):
- onclick = (row.get("onclick") or "").strip()
- url = ""
- if onclick:
- match = re.search(r"location=['\\\"]([^'\\\"]+)['\\\"]", onclick)
- if match:
- url = _absolute_url(match.group(1))
- if not url:
- anchor = row.find("a", href=True)
- url = _absolute_url(anchor.get("href")) if anchor else ""
- if url:
- _log_parsed_url(url)
-
- number_tag = row.select_one(".episode-number-cell")
- number_text = (number_tag.get_text(strip=True) if number_tag else "").strip()
- match = re.search(r"/episode-(\d+)", url) if url else None
- if match:
- number = int(match.group(1))
- else:
- digits = "".join(ch for ch in number_text if ch.isdigit())
- number = int(digits) if digits else index + 1
-
- title_tag = row.select_one(".episode-title-ger")
- original_tag = row.select_one(".episode-title-eng")
- title = (title_tag.get_text(strip=True) if title_tag else "").strip()
- original_title = (original_tag.get_text(strip=True) if original_tag else "").strip()
- if not title:
- title = f"Episode {number}"
-
- hosters: List[str] = []
- for img in row.select(".episode-watch-cell img"):
- label = (img.get("alt") or img.get("title") or "").strip()
- if label and label not in hosters:
- hosters.append(label)
-
- languages: List[str] = []
- for flag in row.select(".episode-language-cell .watch-language"):
- classes = flag.get("class") or []
- if isinstance(classes, str):
- classes = classes.split()
- for cls in classes:
- if cls.startswith("svg-flag-"):
- key = cls.replace("svg-flag-", "").strip()
- if not key:
- continue
- value = language_map.get(key, key.upper())
- if value and value not in languages:
- languages.append(value)
-
- episodes.append(
- EpisodeInfo(
- number=number,
- title=title,
- original_title=original_title,
- url=url,
- season_label=season_label,
- languages=languages,
- hosters=hosters,
- )
- )
- if episodes:
- return episodes
- return episodes
-
-
-def fetch_episode_stream_link(
- episode_url: str,
- *,
- preferred_hosters: Optional[List[str]] = None,
-) -> Optional[str]:
- _ensure_requests()
- normalized_url = _absolute_url(episode_url)
- preferred = [hoster.lower() for hoster in (preferred_hosters or DEFAULT_PREFERRED_HOSTERS)]
- session = get_requests_session("serienstream", headers=HEADERS)
- # Preflight optional: Startseite kann 5xx liefern, Zielseite aber funktionieren.
- try:
- _get_soup(BASE_URL, session=session)
- except Exception:
- pass
- soup = _get_soup(normalized_url, session=session)
- candidates: List[Tuple[str, str]] = []
- for button in soup.select("button.link-box[data-play-url]"):
- play_url = (button.get("data-play-url") or "").strip()
- provider = (button.get("data-provider-name") or "").strip()
- url = _absolute_url(play_url)
- if url:
- _log_parsed_url(url)
- if provider and url:
- candidates.append((provider, url))
- if not candidates:
- return None
- for preferred_name in preferred:
- for name, url in candidates:
- if name.lower() == preferred_name:
- return url
- return candidates[0][1]
-
-
-def fetch_episode_hoster_names(episode_url: str) -> List[str]:
- """Liest die verfügbaren Hoster-Namen für eine Episode aus."""
- _ensure_requests()
- normalized_url = _absolute_url(episode_url)
- session = get_requests_session("serienstream", headers=HEADERS)
- # Preflight optional: Startseite kann 5xx liefern, Zielseite aber funktionieren.
- try:
- _get_soup(BASE_URL, session=session)
- except Exception:
- pass
- soup = _get_soup(normalized_url, session=session)
- names: List[str] = []
- seen: set[str] = set()
- for button in soup.select("button.link-box[data-provider-name]"):
- name = (button.get("data-provider-name") or "").strip()
- play_url = (button.get("data-play-url") or "").strip()
- url = _absolute_url(play_url)
- if url:
- _log_parsed_url(url)
- key = name.casefold().strip()
- if not key or key in seen:
- continue
- seen.add(key)
- names.append(name)
- _log_url(name, kind="HOSTER")
- if names:
- _log_url(f"{normalized_url}#hosters={','.join(names)}", kind="HOSTERS")
- return names
-
-
-_LATEST_EPISODE_TAG_RE = re.compile(SEASON_EPISODE_TAG, re.IGNORECASE)
-_LATEST_EPISODE_URL_RE = re.compile(SEASON_EPISODE_URL, re.IGNORECASE)
-
-
-def _extract_latest_episodes(soup: BeautifulSoupT) -> List[LatestEpisode]:
- """Parst die neuesten Episoden von der Startseite."""
- episodes: List[LatestEpisode] = []
- seen: set[str] = set()
-
- for anchor in soup.select("a.latest-episode-row[href]"):
- href = (anchor.get("href") or "").strip()
- if not href or "/serie/" not in href:
- continue
- url = _absolute_url(href)
- if not url:
- continue
-
- title_node = anchor.select_one(".ep-title")
- series_title = (title_node.get("title") if title_node else "") or ""
- series_title = series_title.strip() or (title_node.get_text(strip=True) if title_node else "").strip()
- if not series_title:
- continue
-
- season_text = (anchor.select_one(".ep-season").get_text(strip=True) if anchor.select_one(".ep-season") else "").strip()
- episode_text = (anchor.select_one(".ep-episode").get_text(strip=True) if anchor.select_one(".ep-episode") else "").strip()
- season_number: Optional[int] = None
- episode_number: Optional[int] = None
- match = re.search(r"S\\s*(\\d+)", season_text, re.IGNORECASE)
- if match:
- season_number = int(match.group(1))
- match = re.search(r"E\\s*(\\d+)", episode_text, re.IGNORECASE)
- if match:
- episode_number = int(match.group(1))
- if season_number is None or episode_number is None:
- match = _LATEST_EPISODE_URL_RE.search(href)
- if match:
- season_number = int(match.group(1))
- episode_number = int(match.group(2))
- if season_number is None or episode_number is None:
- continue
-
- airdate_node = anchor.select_one(".ep-time")
- airdate = (airdate_node.get_text(" ", strip=True) if airdate_node else "").strip()
-
- key = f"{url}\\t{season_number}\\t{episode_number}"
- if key in seen:
- continue
- seen.add(key)
-
- _log_parsed_url(url)
- episodes.append(
- LatestEpisode(
- series_title=series_title,
- season=int(season_number),
- episode=int(episode_number),
- url=url,
- airdate=airdate,
- )
- )
-
- return episodes
-
-
-def resolve_redirect(target_url: str) -> Optional[str]:
- _ensure_requests()
- normalized_url = _absolute_url(target_url)
- _log_visit(normalized_url)
- session = get_requests_session("serienstream", headers=HEADERS)
- # Preflight optional: Startseite kann 5xx liefern, Zielseite aber funktionieren.
- try:
- _get_soup(BASE_URL, session=session)
- except Exception:
- pass
- response = session.get(
- normalized_url,
- headers=HEADERS,
- timeout=DEFAULT_TIMEOUT,
- allow_redirects=True,
- )
- if response.url:
- _log_url(response.url, kind="RESOLVED")
- return response.url if response.url else None
-
-
-def scrape_series_detail(
- series_identifier: str,
- max_seasons: Optional[int] = None,
-) -> List[SeasonInfo]:
- _ensure_requests()
- series_url = _series_root_url(_normalize_series_url(series_identifier))
- _log_url(series_url, kind="SERIES")
- _notify_url(series_url)
- session = get_requests_session("serienstream", headers=HEADERS)
- # Preflight ist optional; manche Umgebungen/Provider leiten die Startseite um.
- try:
- _get_soup(BASE_URL, session=session)
- except Exception:
- pass
- soup = _get_soup(series_url, session=session)
-
- base_series_url = _series_root_url(_extract_canonical_url(soup, series_url))
- season_links = _extract_season_links(soup)
- season_count = _extract_number_of_seasons(soup)
- if season_count and (not season_links or len(season_links) < season_count):
- existing = {number for number, _ in season_links}
- for number in range(1, season_count + 1):
- if number in existing:
- continue
- season_url = f"{base_series_url}/staffel-{number}"
- _log_parsed_url(season_url)
- season_links.append((number, season_url))
- season_links.sort(key=lambda item: item[0])
- if max_seasons is not None:
- season_links = season_links[:max_seasons]
- seasons: List[SeasonInfo] = []
- for number, url in season_links:
- season_soup = _get_soup(url, session=session)
- episodes = _extract_episodes(season_soup)
- seasons.append(SeasonInfo(number=number, url=url, episodes=episodes))
- seasons.sort(key=lambda s: s.number)
- return seasons
-
-
-class SerienstreamPlugin(BasisPlugin):
- """Downloader-Plugin, das Serien von s.to ueber requests/bs4 bereitstellt."""
-
- name = "Serienstream (s.to)"
- POPULAR_GENRE_LABEL = "⭐ Beliebte Serien"
-
- def __init__(self) -> None:
- self._series_results: Dict[str, SeriesResult] = {}
- self._season_cache: Dict[str, List[SeasonInfo]] = {}
- self._episode_label_cache: Dict[Tuple[str, str], Dict[str, EpisodeInfo]] = {}
- self._catalog_cache: Optional[Dict[str, List[SeriesResult]]] = None
- self._popular_cache: Optional[List[SeriesResult]] = None
- self._requests_available = REQUESTS_AVAILABLE
- self._default_preferred_hosters: List[str] = list(DEFAULT_PREFERRED_HOSTERS)
- self._preferred_hosters: List[str] = list(self._default_preferred_hosters)
- self._hoster_cache: Dict[Tuple[str, str, str], List[str]] = {}
- self._latest_cache: Dict[int, List[LatestEpisode]] = {}
- self._latest_hoster_cache: Dict[str, List[str]] = {}
- self.is_available = True
- self.unavailable_reason: Optional[str] = None
- if not self._requests_available: # pragma: no cover - optional dependency
- self.is_available = False
- self.unavailable_reason = (
- "requests/bs4 fehlen. Installiere 'requests' und 'beautifulsoup4'."
- )
- print(
- "SerienstreamPlugin deaktiviert: requests/bs4 fehlen. "
- "Installiere 'requests' und 'beautifulsoup4'."
- )
- if REQUESTS_IMPORT_ERROR:
- print(f"Importfehler: {REQUESTS_IMPORT_ERROR}")
- return
-
- def _ensure_catalog(self) -> Dict[str, List[SeriesResult]]:
- if self._catalog_cache is not None:
- return self._catalog_cache
- # Stand: 2026-01 liefert `?by=genre` konsistente Gruppen für `genres()`.
- catalog_url = f"{BASE_URL}/serien?by=genre"
- soup = _get_soup_simple(catalog_url)
- self._catalog_cache = parse_series_catalog(soup)
- return self._catalog_cache
-
- def genres(self) -> List[str]:
- """Optional: Liefert alle Genres aus dem Serien-Katalog."""
- if not self._requests_available:
- return []
- catalog = self._ensure_catalog()
- return sorted(catalog.keys(), key=str.casefold)
-
- def capabilities(self) -> set[str]:
- """Meldet unterstützte Features für Router-Menüs."""
- return {"popular_series", "genres", "latest_episodes"}
-
- def popular_series(self) -> List[str]:
- """Liefert die Titel der beliebten Serien (Quelle: `/beliebte-serien`)."""
- if not self._requests_available:
- return []
- entries = self._ensure_popular()
- self._series_results.update({entry.title: entry for entry in entries if entry.title})
- return [entry.title for entry in entries if entry.title]
-
- def titles_for_genre(self, genre: str) -> List[str]:
- """Optional: Liefert Titel für ein Genre."""
- if not self._requests_available:
- return []
- genre = (genre or "").strip()
- if not genre:
- return []
- if genre == self.POPULAR_GENRE_LABEL:
- return self.popular_series()
- catalog = self._ensure_catalog()
- entries = catalog.get(genre, [])
- self._series_results.update({entry.title: entry for entry in entries if entry.title})
- return [entry.title for entry in entries if entry.title]
-
- def _ensure_popular(self) -> List[SeriesResult]:
- """Laedt und cached die Liste der beliebten Serien aus `/beliebte-serien`."""
- if self._popular_cache is not None:
- return list(self._popular_cache)
- soup = _get_soup_simple(POPULAR_SERIES_URL)
- results: List[SeriesResult] = []
- seen: set[str] = set()
-
- # Neues Layout (Stand: 2026-01): Abschnitt "Meistgesehen" hat Karten mit
- # `a.show-card` und Titel im `img alt=...`.
- anchors = None
- for section in soup.select("div.mb-5"):
- h2 = section.select_one("h2")
- label = (h2.get_text(" ", strip=True) if h2 else "").casefold()
- if "meistgesehen" in label:
- anchors = section.select("a.show-card[href]")
- break
- if anchors is None:
- anchors = soup.select("a.show-card[href]")
-
- for anchor in anchors:
- href = (anchor.get("href") or "").strip()
- if not href or "/serie/" not in href:
- continue
- img = anchor.select_one("img[alt]")
- title = ((img.get("alt") if img else "") or "").strip()
- if not title or title in seen:
- continue
- url = _absolute_url(href).split("#", 1)[0].split("?", 1)[0].rstrip("/")
- url = re.sub(r"/staffel-\\d+(?:/.*)?$", "", url).rstrip("/")
- if not url:
- continue
- _log_parsed_url(url)
- seen.add(title)
- results.append(SeriesResult(title=title, description="", url=url))
-
-
- self._popular_cache = list(results)
- return list(results)
-
- @staticmethod
- def _season_label(number: int) -> str:
- return f"Staffel {number}"
-
- @staticmethod
- def _episode_label(info: EpisodeInfo) -> str:
- suffix_parts: List[str] = []
- if info.original_title:
- suffix_parts.append(info.original_title)
- # Staffel nicht im Episoden-Label anzeigen (wird im UI bereits gesetzt).
- suffix = f" ({' | '.join(suffix_parts)})" if suffix_parts else ""
-
- return f"Episode {info.number}: {info.title}{suffix}"
-
- @staticmethod
- def _parse_season_number(label: str) -> Optional[int]:
- digits = "".join(ch for ch in label if ch.isdigit())
- if not digits:
- return None
- return int(digits)
-
- def _clear_episode_cache_for_title(self, title: str) -> None:
- keys_to_remove = [key for key in self._episode_label_cache if key[0] == title]
- for key in keys_to_remove:
- self._episode_label_cache.pop(key, None)
- keys_to_remove = [key for key in self._hoster_cache if key[0] == title]
- for key in keys_to_remove:
- self._hoster_cache.pop(key, None)
-
- def _cache_episode_labels(self, title: str, season_label: str, season_info: SeasonInfo) -> None:
- cache_key = (title, season_label)
- self._episode_label_cache[cache_key] = {
- self._episode_label(info): info for info in season_info.episodes
- }
-
- def _lookup_episode(self, title: str, season_label: str, episode_label: str) -> Optional[EpisodeInfo]:
- cache_key = (title, season_label)
- cached = self._episode_label_cache.get(cache_key)
- if cached:
- return cached.get(episode_label)
-
- seasons = self._ensure_seasons(title)
- number = self._parse_season_number(season_label)
- if number is None:
- return None
-
- for season_info in seasons:
- if season_info.number == number:
- self._cache_episode_labels(title, season_label, season_info)
- return self._episode_label_cache.get(cache_key, {}).get(episode_label)
- return None
-
- async def search_titles(self, query: str) -> List[str]:
- query = query.strip()
- if not query:
- self._series_results.clear()
- self._season_cache.clear()
- self._episode_label_cache.clear()
- self._catalog_cache = None
- return []
- if not self._requests_available:
- raise RuntimeError("SerienstreamPlugin kann ohne requests/bs4 nicht suchen.")
- try:
- # Nutzt den Katalog (/serien), der jetzt nach Genres gruppiert ist.
- # Alternativ gäbe es ein Ajax-Endpoint, aber der ist nicht immer zuverlässig erreichbar.
- results = search_series(query)
- except Exception as exc: # pragma: no cover - defensive logging
- self._series_results.clear()
- self._season_cache.clear()
- self._episode_label_cache.clear()
- self._catalog_cache = None
- raise RuntimeError(f"Serienstream-Suche fehlgeschlagen: {exc}") from exc
- self._series_results = {result.title: result for result in results}
- self._season_cache.clear()
- self._episode_label_cache.clear()
- return [result.title for result in results]
-
- def _ensure_seasons(self, title: str) -> List[SeasonInfo]:
- if title in self._season_cache:
- seasons = self._season_cache[title]
- # Auch bei Cache-Treffern die URLs loggen, damit nachvollziehbar bleibt,
- # welche Seiten für Staffel-/Episodenlisten relevant sind.
- if _get_setting_bool(GLOBAL_SETTING_LOG_URLS, default=False):
- series = self._series_results.get(title)
- if series and series.url:
- _log_url(series.url, kind="CACHE")
- for season in seasons:
- if season.url:
- _log_url(season.url, kind="CACHE")
- return seasons
- series = self._series_results.get(title)
- if not series:
- # Kodi startet das Plugin pro Navigation neu -> Such-Cache im RAM geht verloren.
- # Daher den Titel erneut im Katalog auflösen, um die Serien-URL zu bekommen.
- catalog = self._ensure_catalog()
- lookup_key = title.casefold().strip()
- for entries in catalog.values():
- for entry in entries:
- if entry.title.casefold().strip() == lookup_key:
- series = entry
- self._series_results[entry.title] = entry
- break
- if series:
- break
- if not series:
- return []
- try:
- seasons = scrape_series_detail(series.url)
- except Exception as exc: # pragma: no cover - defensive logging
- raise RuntimeError(f"Serienstream-Staffeln konnten nicht geladen werden: {exc}") from exc
- self._clear_episode_cache_for_title(title)
- self._season_cache[title] = seasons
- return seasons
-
- def seasons_for(self, title: str) -> List[str]:
- seasons = self._ensure_seasons(title)
- # Serienstream liefert gelegentlich Staffeln ohne Episoden (z.B. Parsing-/Layoutwechsel).
- # Diese sollen im UI nicht als auswählbarer Menüpunkt erscheinen.
- return [self._season_label(season.number) for season in seasons if season.episodes]
-
- def episodes_for(self, title: str, season: str) -> List[str]:
- seasons = self._ensure_seasons(title)
- number = self._parse_season_number(season)
- if number is None:
- return []
- for season_info in seasons:
- if season_info.number == number:
- labels = [self._episode_label(info) for info in season_info.episodes]
- self._cache_episode_labels(title, season, season_info)
- return labels
- return []
-
- def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]:
- if not self._requests_available:
- raise RuntimeError("SerienstreamPlugin kann ohne requests/bs4 keine Stream-Links liefern.")
- episode_info = self._lookup_episode(title, season, episode)
- if not episode_info:
- return None
- try:
- link = fetch_episode_stream_link(
- episode_info.url,
- preferred_hosters=self._preferred_hosters,
- )
- if link:
- _log_url(link, kind="FOUND")
- return link
- except Exception as exc: # pragma: no cover - defensive logging
- raise RuntimeError(f"Stream-Link konnte nicht geladen werden: {exc}") from exc
-
- def available_hosters_for(self, title: str, season: str, episode: str) -> List[str]:
- if not self._requests_available:
- raise RuntimeError("SerienstreamPlugin kann ohne requests/bs4 keine Hoster laden.")
- cache_key = (title, season, episode)
- cached = self._hoster_cache.get(cache_key)
- if cached is not None:
- return list(cached)
-
- episode_info = self._lookup_episode(title, season, episode)
- if not episode_info:
- return []
- try:
- names = fetch_episode_hoster_names(episode_info.url)
- except Exception as exc: # pragma: no cover - defensive logging
- raise RuntimeError(f"Hoster konnten nicht geladen werden: {exc}") from exc
- self._hoster_cache[cache_key] = list(names)
- return list(names)
-
- def latest_episodes(self, page: int = 1) -> List[LatestEpisode]:
- """Liefert die neuesten Episoden aus `/neue-episoden`."""
- if not self._requests_available:
- return []
- try:
- page = int(page or 1)
- except Exception:
- page = 1
- page = max(1, page)
- cached = self._latest_cache.get(page)
- if cached is not None:
- return list(cached)
-
- url = LATEST_EPISODES_URL
- if page > 1:
- url = f"{url}?page={page}"
- soup = _get_soup_simple(url)
- episodes = _extract_latest_episodes(soup)
- self._latest_cache[page] = list(episodes)
- return list(episodes)
-
- def available_hosters_for_url(self, episode_url: str) -> List[str]:
- if not self._requests_available:
- raise RuntimeError("SerienstreamPlugin kann ohne requests/bs4 keine Hoster laden.")
- normalized = _absolute_url(episode_url)
- cached = self._latest_hoster_cache.get(normalized)
- if cached is not None:
- return list(cached)
- try:
- names = fetch_episode_hoster_names(normalized)
- except Exception as exc: # pragma: no cover - defensive logging
- raise RuntimeError(f"Hoster konnten nicht geladen werden: {exc}") from exc
- self._latest_hoster_cache[normalized] = list(names)
- return list(names)
-
- def stream_link_for_url(self, episode_url: str) -> Optional[str]:
- if not self._requests_available:
- raise RuntimeError("SerienstreamPlugin kann ohne requests/bs4 keine Stream-Links liefern.")
- normalized = _absolute_url(episode_url)
- try:
- link = fetch_episode_stream_link(
- normalized,
- preferred_hosters=self._preferred_hosters,
- )
- if link:
- _log_url(link, kind="FOUND")
- return link
- except Exception as exc: # pragma: no cover - defensive logging
- raise RuntimeError(f"Stream-Link konnte nicht geladen werden: {exc}") from exc
-
- def resolve_stream_link(self, link: str) -> Optional[str]:
- if not self._requests_available:
- raise RuntimeError("SerienstreamPlugin kann ohne requests/bs4 keine Stream-Links aufloesen.")
- try:
- resolved = resolve_redirect(link)
- if not resolved:
- return None
- try:
- from resolveurl_backend import resolve as resolve_with_resolveurl
- except Exception:
- resolve_with_resolveurl = None
- if callable(resolve_with_resolveurl):
- resolved_by_resolveurl = resolve_with_resolveurl(resolved)
- if resolved_by_resolveurl:
- _log_url("ResolveURL", kind="HOSTER_RESOLVER")
- _log_url(resolved_by_resolveurl, kind="MEDIA")
- return resolved_by_resolveurl
- _log_url(resolved, kind="FINAL")
- return resolved
- except Exception as exc: # pragma: no cover - defensive logging
- raise RuntimeError(f"Stream-Link konnte nicht verfolgt werden: {exc}") from exc
-
- def set_preferred_hosters(self, hosters: List[str]) -> None:
- normalized = [hoster.strip().lower() for hoster in hosters if hoster.strip()]
- if normalized:
- self._preferred_hosters = normalized
-
- def reset_preferred_hosters(self) -> None:
- self._preferred_hosters = list(self._default_preferred_hosters)
-
-
-# Alias für die automatische Plugin-Erkennung.
-Plugin = SerienstreamPlugin
diff --git a/dist/plugin.video.viewit/plugins/topstreamfilm_plugin.py b/dist/plugin.video.viewit/plugins/topstreamfilm_plugin.py
deleted file mode 100644
index 7e03ebc..0000000
--- a/dist/plugin.video.viewit/plugins/topstreamfilm_plugin.py
+++ /dev/null
@@ -1,1027 +0,0 @@
-"""HTML-basierte Integration fuer eine Streaming-/Mediathek-Seite (Template).
-
-Dieses Plugin ist als Startpunkt gedacht, um eine eigene/autorisiert betriebene
-Seite mit einer HTML-Suche in ViewIt einzubinden.
-
-Hinweise:
-- Nutzt optional `requests` + `beautifulsoup4` (bs4).
-- `search_titles` liefert eine Trefferliste (Titel-Strings).
-- `seasons_for` / `episodes_for` können für Filme als Single-Season/Single-Episode
- modelliert werden (z.B. Staffel 1, Episode 1) oder komplett leer bleiben,
- solange nur Serien unterstützt werden.
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from datetime import datetime
-import hashlib
-import os
-import re
-import json
-from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeAlias
-from urllib.parse import urlencode, urljoin
-
-try: # pragma: no cover - optional dependency
- import requests
- from bs4 import BeautifulSoup # type: ignore[import-not-found]
-except ImportError as exc: # pragma: no cover - optional dependency
- requests = None
- BeautifulSoup = None
- REQUESTS_AVAILABLE = False
- REQUESTS_IMPORT_ERROR = exc
-else:
- REQUESTS_AVAILABLE = True
- REQUESTS_IMPORT_ERROR = None
-
-try: # pragma: no cover - optional Kodi helpers
- import xbmcaddon # type: ignore[import-not-found]
- import xbmcvfs # type: ignore[import-not-found]
- import xbmcgui # type: ignore[import-not-found]
-except ImportError: # pragma: no cover - allow running outside Kodi
- xbmcaddon = None
- xbmcvfs = None
- xbmcgui = None
-
-from plugin_interface import BasisPlugin
-from plugin_helpers import dump_response_html, get_setting_bool, log_url, notify_url
-from regex_patterns import DIGITS
-
-if TYPE_CHECKING: # pragma: no cover
- from requests import Session as RequestsSession
- from bs4 import BeautifulSoup as BeautifulSoupT # type: ignore[import-not-found]
-else: # pragma: no cover
- RequestsSession: TypeAlias = Any
- BeautifulSoupT: TypeAlias = Any
-
-
-ADDON_ID = "plugin.video.viewit"
-SETTING_BASE_URL = "topstream_base_url"
-DEFAULT_BASE_URL = "https://www.meineseite"
-GLOBAL_SETTING_LOG_URLS = "debug_log_urls"
-GLOBAL_SETTING_DUMP_HTML = "debug_dump_html"
-GLOBAL_SETTING_SHOW_URL_INFO = "debug_show_url_info"
-SETTING_GENRE_MAX_PAGES = "topstream_genre_max_pages"
-DEFAULT_TIMEOUT = 20
-DEFAULT_PREFERRED_HOSTERS = ["supervideo", "dropload", "voe"]
-MEINECLOUD_HOST = "meinecloud.click"
-DEFAULT_GENRE_MAX_PAGES = 20
-HARD_MAX_GENRE_PAGES = 200
-HEADERS = {
- "User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)",
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
- "Connection": "keep-alive",
-}
-
-
-@dataclass(frozen=True)
-class SearchHit:
- """Interner Treffer mit Title + URL."""
-
- title: str
- url: str
- description: str = ""
-
-
-def _normalize_search_text(value: str) -> str:
- """Normalisiert Text für robuste, wortbasierte Suche/Filter.
-
- Wir ersetzen Nicht-Alphanumerisches durch Leerzeichen und kollabieren Whitespace.
- Dadurch kann z.B. "Star Trek: Lower Decks – Der Film" sauber auf Tokens gematcht werden.
- """
-
- value = (value or "").casefold()
- value = re.sub(r"[^a-z0-9]+", " ", value)
- value = re.sub(r"\s+", " ", value).strip()
- return value
-
-
-def _matches_query(query: str, *, title: str, description: str) -> bool:
- normalized_query = _normalize_search_text(query)
- if not normalized_query:
- return False
- haystack = _normalize_search_text(title)
- if not haystack:
- return False
- return normalized_query in haystack
-
-
-def _strip_der_film_suffix(title: str) -> str:
- """Entfernt den Suffix 'Der Film' am Ende, z.B. 'Star Trek – Der Film'."""
- title = (title or "").strip()
- if not title:
- return ""
- title = re.sub(r"\s*[-–]\s*der\s+film\s*$", "", title, flags=re.IGNORECASE).strip()
- return title
-
-
-class TopstreamfilmPlugin(BasisPlugin):
- """Integration fuer eine HTML-basierte Suchseite."""
-
- name = "TopStreamFilm"
-
- def __init__(self) -> None:
- self._session: RequestsSession | None = None
- self._title_to_url: Dict[str, str] = {}
- self._genre_to_url: Dict[str, str] = {}
- self._movie_iframe_url: Dict[str, str] = {}
- self._movie_title_hint: set[str] = set()
- self._genre_last_page: Dict[str, int] = {}
- self._season_cache: Dict[str, List[str]] = {}
- self._episode_cache: Dict[tuple[str, str], List[str]] = {}
- self._episode_to_url: Dict[tuple[str, str, str], str] = {}
- self._episode_to_hosters: Dict[tuple[str, str, str], Dict[str, str]] = {}
- self._season_to_episode_numbers: Dict[tuple[str, str], List[int]] = {}
- self._episode_title_by_number: Dict[tuple[str, int, int], str] = {}
- self._detail_html_cache: Dict[str, str] = {}
- self._popular_cache: List[str] | None = None
- self._default_preferred_hosters: List[str] = list(DEFAULT_PREFERRED_HOSTERS)
- self._preferred_hosters: List[str] = list(self._default_preferred_hosters)
- self.is_available = REQUESTS_AVAILABLE
- self.unavailable_reason = None if REQUESTS_AVAILABLE else f"requests/bs4 fehlen: {REQUESTS_IMPORT_ERROR}"
- self._load_title_url_cache()
- self._load_genre_cache()
-
- def _cache_dir(self) -> str:
- if xbmcaddon and xbmcvfs:
- try:
- addon = xbmcaddon.Addon(ADDON_ID)
- profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
- if not xbmcvfs.exists(profile):
- xbmcvfs.mkdirs(profile)
- return profile
- except Exception:
- pass
- return os.path.dirname(__file__)
-
- def _title_url_cache_path(self) -> str:
- return os.path.join(self._cache_dir(), "topstream_title_url_cache.json")
-
- def _load_title_url_cache(self) -> None:
- path = self._title_url_cache_path()
- try:
- if xbmcvfs and xbmcvfs.exists(path):
- handle = xbmcvfs.File(path)
- raw = handle.read()
- handle.close()
- elif os.path.exists(path):
- with open(path, "r", encoding="utf-8") as handle:
- raw = handle.read()
- else:
- return
- loaded = json.loads(raw or "{}")
- if isinstance(loaded, dict):
- # New format: {base_url: {title: url}}
- base_url = self._get_base_url()
- if base_url in loaded and isinstance(loaded.get(base_url), dict):
- loaded = loaded.get(base_url) or {}
- # Backwards compatible: {title: url}
- for title, url in (loaded or {}).items():
- if isinstance(title, str) and isinstance(url, str) and title.strip() and url.strip():
- self._title_to_url.setdefault(title.strip(), url.strip())
- except Exception:
- return
-
- def _save_title_url_cache(self) -> None:
- path = self._title_url_cache_path()
- try:
- base_url = self._get_base_url()
- store: Dict[str, Dict[str, str]] = {}
- # merge with existing
- try:
- if xbmcvfs and xbmcvfs.exists(path):
- handle = xbmcvfs.File(path)
- existing_raw = handle.read()
- handle.close()
- elif os.path.exists(path):
- with open(path, "r", encoding="utf-8") as handle:
- existing_raw = handle.read()
- else:
- existing_raw = ""
- existing = json.loads(existing_raw or "{}")
- if isinstance(existing, dict):
- if all(isinstance(k, str) and isinstance(v, dict) for k, v in existing.items()):
- store = {k: dict(v) for k, v in existing.items()} # type: ignore[arg-type]
- except Exception:
- store = {}
-
- store[base_url] = dict(self._title_to_url)
- payload = json.dumps(store, ensure_ascii=False, sort_keys=True)
- except Exception:
- return
- try:
- if xbmcaddon and xbmcvfs:
- directory = os.path.dirname(path)
- if directory and not xbmcvfs.exists(directory):
- xbmcvfs.mkdirs(directory)
- handle = xbmcvfs.File(path, "w")
- handle.write(payload)
- handle.close()
- else:
- with open(path, "w", encoding="utf-8") as handle:
- handle.write(payload)
- except Exception:
- return
-
- def _genre_cache_path(self) -> str:
- return os.path.join(self._cache_dir(), "topstream_genres_cache.json")
-
- def _load_genre_cache(self) -> None:
- path = self._genre_cache_path()
- try:
- if xbmcvfs and xbmcvfs.exists(path):
- handle = xbmcvfs.File(path)
- raw = handle.read()
- handle.close()
- elif os.path.exists(path):
- with open(path, "r", encoding="utf-8") as handle:
- raw = handle.read()
- else:
- return
- loaded = json.loads(raw or "{}")
- if isinstance(loaded, dict):
- base_url = self._get_base_url()
- mapping = loaded.get(base_url)
- if isinstance(mapping, dict):
- for genre, url in mapping.items():
- if isinstance(genre, str) and isinstance(url, str) and genre.strip() and url.strip():
- self._genre_to_url.setdefault(genre.strip(), url.strip())
- except Exception:
- return
-
- def _save_genre_cache(self) -> None:
- path = self._genre_cache_path()
- try:
- base_url = self._get_base_url()
- store: Dict[str, Dict[str, str]] = {}
- try:
- if xbmcvfs and xbmcvfs.exists(path):
- handle = xbmcvfs.File(path)
- existing_raw = handle.read()
- handle.close()
- elif os.path.exists(path):
- with open(path, "r", encoding="utf-8") as handle:
- existing_raw = handle.read()
- else:
- existing_raw = ""
- existing = json.loads(existing_raw or "{}")
- if isinstance(existing, dict):
- if all(isinstance(k, str) and isinstance(v, dict) for k, v in existing.items()):
- store = {k: dict(v) for k, v in existing.items()} # type: ignore[arg-type]
- except Exception:
- store = {}
- store[base_url] = dict(self._genre_to_url)
- payload = json.dumps(store, ensure_ascii=False, sort_keys=True)
- except Exception:
- return
- try:
- if xbmcaddon and xbmcvfs:
- directory = os.path.dirname(path)
- if directory and not xbmcvfs.exists(directory):
- xbmcvfs.mkdirs(directory)
- handle = xbmcvfs.File(path, "w")
- handle.write(payload)
- handle.close()
- else:
- with open(path, "w", encoding="utf-8") as handle:
- handle.write(payload)
- except Exception:
- return
-
- def _get_session(self) -> RequestsSession:
- if requests is None:
- raise RuntimeError(self.unavailable_reason or "requests nicht verfügbar.")
- if self._session is None:
- session = requests.Session()
- session.headers.update(HEADERS)
- self._session = session
- return self._session
-
- def _get_base_url(self) -> str:
- base = DEFAULT_BASE_URL
- if xbmcaddon is not None:
- try:
- addon = xbmcaddon.Addon(ADDON_ID)
- raw = (addon.getSetting(SETTING_BASE_URL) or "").strip()
- if raw:
- base = raw
- except Exception:
- pass
- base = (base or "").strip()
- if not base:
- return DEFAULT_BASE_URL
- if not base.startswith("http://") and not base.startswith("https://"):
- base = "https://" + base
- return base.rstrip("/")
-
- def _absolute_url(self, href: str) -> str:
- return urljoin(self._get_base_url() + "/", href or "")
-
- @staticmethod
- def _absolute_external_url(href: str, *, base: str = "") -> str:
- href = (href or "").strip()
- if not href:
- return ""
- if href.startswith("//"):
- return "https:" + href
- if href.startswith("http://") or href.startswith("https://"):
- return href
- if base:
- return urljoin(base if base.endswith("/") else base + "/", href)
- return href
-
- def _get_setting_bool(self, setting_id: str, *, default: bool = False) -> bool:
- return get_setting_bool(ADDON_ID, setting_id, default=default)
-
- def _get_setting_int(self, setting_id: str, *, default: int) -> int:
- if xbmcaddon is None:
- return default
- try:
- addon = xbmcaddon.Addon(ADDON_ID)
- getter = getattr(addon, "getSettingInt", None)
- if callable(getter):
- return int(getter(setting_id))
- raw = str(addon.getSetting(setting_id) or "").strip()
- return int(raw) if raw else default
- except Exception:
- return default
-
- def _notify_url(self, url: str) -> None:
- notify_url(ADDON_ID, heading=self.name, url=url, enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO)
-
- def _log_url(self, url: str, *, kind: str = "VISIT") -> None:
- log_url(ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, log_filename="topstream_urls.log", url=url, kind=kind)
-
- def _log_response_html(self, url: str, body: str) -> None:
- dump_response_html(
- ADDON_ID,
- enabled_setting_id=GLOBAL_SETTING_DUMP_HTML,
- url=url,
- body=body,
- filename_prefix="topstream_response",
- )
-
- def capabilities(self) -> set[str]:
- return {"genres", "popular_series"}
-
- def _popular_url(self) -> str:
- return self._absolute_url("/beliebte-filme-online.html")
-
- def popular_series(self) -> List[str]:
- """Liefert die "Meist gesehen"/"Beliebte Filme" Liste.
-
- Quelle: `/beliebte-filme-online.html` (TopStreamFilm Template).
- """
- if self._popular_cache is not None:
- return list(self._popular_cache)
- if not REQUESTS_AVAILABLE or BeautifulSoup is None:
- self._popular_cache = []
- return []
- try:
- soup = self._get_soup(self._popular_url())
- except Exception:
- self._popular_cache = []
- return []
-
- hits = self._parse_listing_titles(soup)
- titles: List[str] = []
- seen: set[str] = set()
- for hit in hits:
- if not hit.title or hit.title in seen:
- continue
- seen.add(hit.title)
- self._title_to_url[hit.title] = hit.url
- titles.append(hit.title)
- if titles:
- self._save_title_url_cache()
- self._popular_cache = list(titles)
- return list(titles)
-
- def _parse_genres_from_home(self, soup: BeautifulSoupT) -> Dict[str, str]:
- genres: Dict[str, str] = {}
- if soup is None:
- return genres
-
- # Primär: im Header-Menü unter "KATEGORIEN"
- categories_anchor = None
- for anchor in soup.select("li.menu-item-has-children a"):
- text = (anchor.get_text(" ", strip=True) or "").strip().casefold()
- if text == "kategorien":
- categories_anchor = anchor
- break
- if categories_anchor is not None:
- try:
- parent = categories_anchor.find_parent("li")
- except Exception:
- parent = None
- if parent is not None:
- for anchor in parent.select("ul.sub-menu li.cat-item a[href]"):
- name = (anchor.get_text(" ", strip=True) or "").strip()
- href = (anchor.get("href") or "").strip()
- if not name or not href:
- continue
- genres[name] = self._absolute_url(href)
-
- # Fallback: allgemeine cat-item Links (falls Theme anders ist)
- if not genres:
- for anchor in soup.select("li.cat-item a[href]"):
- name = (anchor.get_text(" ", strip=True) or "").strip()
- href = (anchor.get("href") or "").strip()
- if not name or not href:
- continue
- genres[name] = self._absolute_url(href)
-
- return genres
-
- def _extract_first_int(self, value: str) -> Optional[int]:
- match = re.search(DIGITS, value or "")
- return int(match.group(1)) if match else None
-
- def _strip_links_text(self, node: Any) -> str:
- """Extrahiert den Text eines Nodes ohne Linktexte/URLs."""
- if BeautifulSoup is None:
- return ""
- try:
- fragment = BeautifulSoup(str(node), "html.parser")
- for anchor in fragment.select("a"):
- anchor.extract()
- return (fragment.get_text(" ", strip=True) or "").strip()
- except Exception:
- return ""
-
- def _clear_stream_index_for_title(self, title: str) -> None:
- for key in list(self._season_to_episode_numbers.keys()):
- if key[0] == title:
- self._season_to_episode_numbers.pop(key, None)
- for key in list(self._episode_to_hosters.keys()):
- if key[0] == title:
- self._episode_to_hosters.pop(key, None)
- for key in list(self._episode_title_by_number.keys()):
- if key[0] == title:
- self._episode_title_by_number.pop(key, None)
-
- def _parse_stream_accordion(self, soup: BeautifulSoupT, *, title: str) -> None:
- """Parst Staffel/Episode/Hoster-Links aus der Detailseite (Accordion)."""
- if not soup or not title:
- return
-
- accordion = soup.select_one("#se-accordion") or soup.select_one(".su-accordion#se-accordion")
- if accordion is None:
- return
-
- self._clear_stream_index_for_title(title)
-
- for spoiler in accordion.select(".su-spoiler"):
- season_title = spoiler.select_one(".su-spoiler-title")
- if not season_title:
- continue
-
- season_text = (season_title.get_text(" ", strip=True) or "").strip()
- season_number = self._extract_first_int(season_text)
- if season_number is None:
- continue
- season_label = f"Staffel {season_number}"
-
- data_target = (season_title.get("data-target") or "").strip()
- content = spoiler.select_one(data_target) if data_target.startswith("#") else None
- if content is None:
- content = spoiler.select_one(".su-spoiler-content")
- if content is None:
- continue
-
- episode_numbers: set[int] = set()
- for row in content.select(".cu-ss"):
- raw_text = self._strip_links_text(row)
- raw_text = (raw_text or "").strip()
- if not raw_text:
- continue
-
- match = re.search(
- r"(?P\d+)\s*x\s*(?P\d+)\s*(?P.*)$",
- raw_text,
- flags=re.IGNORECASE,
- )
- if not match:
- continue
- row_season = int(match.group("s"))
- episode_number = int(match.group("e"))
- if row_season != season_number:
- continue
-
- rest = (match.group("rest") or "").strip().replace("–", "-")
- # Links stehen als im HTML, d.h. hier bleibt normalerweise nur "Episode X –" übrig.
- if "-" in rest:
- rest = rest.split("-", 1)[0].strip()
- rest = re.sub(r"\bepisode\s*\d+\b", "", rest, flags=re.IGNORECASE).strip()
- rest = re.sub(r"^\W+|\W+$", "", rest).strip()
- if rest:
- self._episode_title_by_number[(title, season_number, episode_number)] = rest
-
- hosters: Dict[str, str] = {}
- for anchor in row.select("a[href]"):
- name = (anchor.get_text(" ", strip=True) or "").strip()
- href = (anchor.get("href") or "").strip()
- if not name or not href:
- continue
- hosters[name] = href
- if not hosters:
- continue
-
- episode_label = f"Episode {episode_number}"
- ep_title = self._episode_title_by_number.get((title, season_number, episode_number), "")
- if ep_title:
- episode_label = f"Episode {episode_number}: {ep_title}"
-
- self._episode_to_hosters[(title, season_label, episode_label)] = hosters
- episode_numbers.add(episode_number)
-
- self._season_to_episode_numbers[(title, season_label)] = sorted(episode_numbers)
-
- def _ensure_stream_index(self, title: str) -> None:
- """Stellt sicher, dass Staffel/Episoden/Hoster aus der Detailseite geparst sind."""
- title = (title or "").strip()
- if not title:
- return
- # Wenn bereits Staffeln im Index sind, nichts tun.
- if any(key[0] == title for key in self._season_to_episode_numbers.keys()):
- return
- soup = self._get_detail_soup(title)
- if soup is None:
- return
- self._parse_stream_accordion(soup, title=title)
-
- def _get_soup(self, url: str) -> BeautifulSoupT:
- if BeautifulSoup is None or not REQUESTS_AVAILABLE:
- raise RuntimeError("requests/bs4 sind nicht verfuegbar.")
- session = self._get_session()
- self._log_url(url, kind="VISIT")
- self._notify_url(url)
- response = session.get(url, timeout=DEFAULT_TIMEOUT)
- response.raise_for_status()
- self._log_url(response.url, kind="OK")
- self._log_response_html(response.url, response.text)
- return BeautifulSoup(response.text, "html.parser")
-
- def _get_detail_soup(self, title: str) -> Optional[BeautifulSoupT]:
- title = (title or "").strip()
- if not title:
- return None
- url = self._title_to_url.get(title)
- if not url:
- return None
- if BeautifulSoup is None or not REQUESTS_AVAILABLE:
- return None
- cached_html = self._detail_html_cache.get(title)
- if cached_html:
- return BeautifulSoup(cached_html, "html.parser")
- soup = self._get_soup(url)
- try:
- self._detail_html_cache[title] = str(soup)
- except Exception:
- pass
- return soup
-
- def _detect_movie_iframe_url(self, soup: BeautifulSoupT) -> str:
- """Erkennt Film-Detailseiten über eingebettetes MeineCloud-iframe."""
- if not soup:
- return ""
- for frame in soup.select("iframe[src]"):
- src = (frame.get("src") or "").strip()
- if not src:
- continue
- if MEINECLOUD_HOST in src:
- return src
- return ""
-
- def _parse_meinecloud_hosters(self, soup: BeautifulSoupT, *, page_url: str) -> Dict[str, str]:
- """Parst Hoster-Mirrors aus MeineCloud (Film-Seite).
-
- Beispiel:
-
- - supervideo
- - dropload
- - 4K Server
-
- """
-
- hosters: Dict[str, str] = {}
- if not soup:
- return hosters
-
- for entry in soup.select("ul._player-mirrors li[data-link]"):
- raw_link = (entry.get("data-link") or "").strip()
- if not raw_link:
- continue
- name = (entry.get_text(" ", strip=True) or "").strip()
- name = name or "Hoster"
- url = self._absolute_external_url(raw_link, base=page_url)
- if not url:
- continue
- hosters[name] = url
-
- # Falls "4K Server" wieder auf eine MeineCloud-Seite zeigt, versuchen wir einmal zu expandieren.
- expanded: Dict[str, str] = {}
- for name, url in list(hosters.items()):
- if MEINECLOUD_HOST in url and "/fullhd/" in url:
- try:
- nested = self._get_soup(url)
- except Exception:
- continue
- nested_hosters = self._parse_meinecloud_hosters(nested, page_url=url)
- for nested_name, nested_url in nested_hosters.items():
- expanded.setdefault(nested_name, nested_url)
- if expanded:
- hosters.update(expanded)
-
- return hosters
-
- def _extract_last_page(self, soup: BeautifulSoupT) -> int:
- """Liest aus `div.wp-pagenavi` die höchste Seitenzahl."""
- if not soup:
- return 1
- numbers: List[int] = []
- for anchor in soup.select("div.wp-pagenavi a"):
- text = (anchor.get_text(" ", strip=True) or "").strip()
- if text.isdigit():
- try:
- numbers.append(int(text))
- except Exception:
- continue
- return max(numbers) if numbers else 1
-
- def _parse_listing_titles(self, soup: BeautifulSoupT) -> List[SearchHit]:
- hits: List[SearchHit] = []
- if not soup:
- return hits
- for item in soup.select("li.TPostMv"):
- anchor = item.select_one("a[href]")
- if not anchor:
- continue
- href = (anchor.get("href") or "").strip()
- if not href:
- continue
- title_tag = anchor.select_one("h3.Title")
- raw_title = title_tag.get_text(" ", strip=True) if title_tag else anchor.get_text(" ", strip=True)
- raw_title = (raw_title or "").strip()
- is_movie_hint = bool(re.search(r"\bder\s+film\b", raw_title, flags=re.IGNORECASE))
- title = _strip_der_film_suffix(raw_title)
- if not title:
- continue
- if is_movie_hint:
- self._movie_title_hint.add(title)
- hits.append(SearchHit(title=title, url=self._absolute_url(href), description=""))
- return hits
-
- def is_movie(self, title: str) -> bool:
- """Schneller Hint (ohne Detail-Request), ob ein Titel ein Film ist."""
- title = (title or "").strip()
- if not title:
- return False
- if title in self._movie_iframe_url or title in self._movie_title_hint:
- return True
- # Robust: Detailseite prüfen.
- # Laut TopStream-Layout sind Serien-Seiten durch `div.serie-menu` (Staffel-Navigation)
- # gekennzeichnet. Fehlt das Element, behandeln wir den Titel als Film.
- soup = self._get_detail_soup(title)
- if soup is None:
- return False
- has_seasons = bool(soup.select_one("div.serie-menu") or soup.select_one(".serie-menu"))
- return not has_seasons
-
- def genre_page_count(self, genre: str) -> int:
- """Optional: Liefert die letzte Seite eines Genres (Pagination)."""
- if not REQUESTS_AVAILABLE or BeautifulSoup is None:
- return 1
- genre = (genre or "").strip()
- if not genre:
- return 1
- if genre in self._genre_last_page:
- return max(1, int(self._genre_last_page[genre] or 1))
- if not self._genre_to_url:
- self.genres()
- url = self._genre_to_url.get(genre)
- if not url:
- return 1
- try:
- soup = self._get_soup(url)
- except Exception:
- return 1
- last_page = self._extract_last_page(soup)
- self._genre_last_page[genre] = max(1, int(last_page or 1))
- return self._genre_last_page[genre]
-
- def titles_for_genre_page(self, genre: str, page: int) -> List[str]:
- """Optional: Liefert Titel für ein Genre und eine konkrete Seite."""
- if not REQUESTS_AVAILABLE or BeautifulSoup is None:
- return []
- genre = (genre or "").strip()
- if not genre:
- return []
- if not self._genre_to_url:
- self.genres()
- base_url = self._genre_to_url.get(genre)
- if not base_url:
- return []
-
- page = max(1, int(page or 1))
- if page == 1:
- url = base_url
- else:
- url = urljoin(base_url.rstrip("/") + "/", f"page/{page}/")
-
- try:
- soup = self._get_soup(url)
- except Exception:
- return []
-
- hits = self._parse_listing_titles(soup)
- titles: List[str] = []
- seen: set[str] = set()
- for hit in hits:
- if hit.title in seen:
- continue
- seen.add(hit.title)
- self._title_to_url[hit.title] = hit.url
- titles.append(hit.title)
- if titles:
- self._save_title_url_cache()
- return titles
-
- def _ensure_title_index(self, title: str) -> None:
- """Stellt sicher, dass Film/Serie-Infos für den Titel geparst sind."""
- title = (title or "").strip()
- if not title:
- return
-
- # Bereits bekannt?
- if title in self._movie_iframe_url:
- return
- if any(key[0] == title for key in self._season_to_episode_numbers.keys()):
- return
-
- soup = self._get_detail_soup(title)
- if soup is None:
- return
-
- movie_url = self._detect_movie_iframe_url(soup)
- if movie_url:
- self._movie_iframe_url[title] = movie_url
- # Film als Single-Season/Single-Episode abbilden, damit ViewIt navigieren kann.
- season_label = "Film"
- episode_label = "Stream"
- self._season_cache[title] = [season_label]
- self._episode_cache[(title, season_label)] = [episode_label]
- try:
- meinecloud_soup = self._get_soup(movie_url)
- hosters = self._parse_meinecloud_hosters(meinecloud_soup, page_url=movie_url)
- except Exception:
- hosters = {}
- self._episode_to_hosters[(title, season_label, episode_label)] = hosters or {"MeineCloud": movie_url}
- return
-
- # Sonst: Serie via Streams-Accordion parsen (falls vorhanden).
- self._parse_stream_accordion(soup, title=title)
-
- async def search_titles(self, query: str) -> List[str]:
- """Sucht Titel ueber eine HTML-Suche.
-
- Erwartetes HTML (Snippet):
- - Treffer: `li.TPostMv a[href]`
- - Titel: `h3.Title`
- """
-
- if not REQUESTS_AVAILABLE:
- return []
- query = (query or "").strip()
- if not query:
- return []
-
- session = self._get_session()
- url = self._get_base_url() + "/"
- params = {"story": query, "do": "search", "subaction": "search"}
- request_url = f"{url}?{urlencode(params)}"
- self._log_url(request_url, kind="GET")
- self._notify_url(request_url)
- response = session.get(
- url,
- params=params,
- timeout=DEFAULT_TIMEOUT,
- )
- response.raise_for_status()
- self._log_url(response.url, kind="OK")
- self._log_response_html(response.url, response.text)
-
- if BeautifulSoup is None:
- return []
- soup = BeautifulSoup(response.text, "html.parser")
-
- hits: List[SearchHit] = []
- for item in soup.select("li.TPostMv"):
- anchor = item.select_one("a[href]")
- if not anchor:
- continue
- href = (anchor.get("href") or "").strip()
- if not href:
- continue
- title_tag = anchor.select_one("h3.Title")
- raw_title = title_tag.get_text(" ", strip=True) if title_tag else anchor.get_text(" ", strip=True)
- raw_title = (raw_title or "").strip()
- is_movie_hint = bool(re.search(r"\bder\s+film\b", raw_title, flags=re.IGNORECASE))
- title = _strip_der_film_suffix(raw_title)
- if not title:
- continue
- if is_movie_hint:
- self._movie_title_hint.add(title)
- description_tag = item.select_one(".TPMvCn .Description")
- description = description_tag.get_text(" ", strip=True) if description_tag else ""
- hit = SearchHit(title=title, url=self._absolute_url(href), description=description)
- if _matches_query(query, title=hit.title, description=hit.description):
- hits.append(hit)
-
- # Dedup + mapping fuer Navigation
- self._title_to_url.clear()
- titles: List[str] = []
- seen: set[str] = set()
- for hit in hits:
- if hit.title in seen:
- continue
- seen.add(hit.title)
- self._title_to_url[hit.title] = hit.url
- titles.append(hit.title)
- self._save_title_url_cache()
- return titles
-
- def genres(self) -> List[str]:
- if not REQUESTS_AVAILABLE or BeautifulSoup is None:
- return []
- if self._genre_to_url:
- return sorted(self._genre_to_url.keys(), key=lambda value: value.casefold())
-
- try:
- soup = self._get_soup(self._get_base_url() + "/")
- except Exception:
- return []
- parsed = self._parse_genres_from_home(soup)
- self._genre_to_url.clear()
- self._genre_to_url.update(parsed)
- self._save_genre_cache()
- return sorted(self._genre_to_url.keys(), key=lambda value: value.casefold())
-
- def titles_for_genre(self, genre: str) -> List[str]:
- if not REQUESTS_AVAILABLE or BeautifulSoup is None:
- return []
- genre = (genre or "").strip()
- if not genre:
- return []
- if not self._genre_to_url:
- self.genres()
- url = self._genre_to_url.get(genre)
- if not url:
- return []
-
- # Backwards-compatible: liefert nur Seite 1 (Paging läuft über titles_for_genre_page()).
- titles = self.titles_for_genre_page(genre, 1)
- titles.sort(key=lambda value: value.casefold())
- return titles
-
- def seasons_for(self, title: str) -> List[str]:
- title = (title or "").strip()
- if not title or not REQUESTS_AVAILABLE or BeautifulSoup is None:
- return []
-
- self._ensure_title_index(title)
- if title in self._movie_iframe_url:
- return ["Film"]
-
- # Primär: Streams-Accordion (enthält echte Staffel-/Episodenlistings).
- self._ensure_stream_index(title)
- seasons = sorted(
- {season_label for (t, season_label) in self._season_to_episode_numbers.keys() if t == title},
- key=lambda value: (self._extract_first_int(value) or 0),
- )
- if seasons:
- self._season_cache[title] = list(seasons)
- return list(seasons)
-
- # Fallback: Staffel-Tabs im Seitenmenü (ohne Links).
- cached = self._season_cache.get(title)
- if cached is not None:
- return list(cached)
-
- soup = self._get_detail_soup(title)
- if soup is None:
- self._season_cache[title] = []
- return []
-
- numbers: List[int] = []
- seen: set[int] = set()
- for anchor in soup.select(
- "div.serie-menu div.tt_season ul.nav a[href^='#season-'],"
- " .serie-menu .tt_season a[href^='#season-'],"
- " a[data-toggle='tab'][href^='#season-']"
- ):
- text = (anchor.get_text(" ", strip=True) or "").strip()
- num = self._extract_first_int(text)
- if num is None:
- href = (anchor.get("href") or "").strip()
- num = self._extract_first_int(href.replace("#season-", ""))
- if num is None or num in seen:
- continue
- seen.add(num)
- numbers.append(num)
-
- seasons = [f"Staffel {n}" for n in sorted(numbers)]
- self._season_cache[title] = list(seasons)
- return list(seasons)
-
- def episodes_for(self, title: str, season: str) -> List[str]:
- title = (title or "").strip()
- season = (season or "").strip()
- if not title or not season or not REQUESTS_AVAILABLE or BeautifulSoup is None:
- return []
-
- self._ensure_title_index(title)
- if title in self._movie_iframe_url and season == "Film":
- return ["Stream"]
-
- cache_key = (title, season)
- cached = self._episode_cache.get(cache_key)
- if cached is not None:
- return list(cached)
-
- self._ensure_stream_index(title)
- episode_numbers = self._season_to_episode_numbers.get((title, season), [])
- episodes: List[str] = []
- season_number = self._extract_first_int(season) or 0
- for ep_no in episode_numbers:
- label = f"Episode {ep_no}"
- ep_title = self._episode_title_by_number.get((title, season_number, ep_no), "")
- if ep_title:
- label = f"Episode {ep_no}: {ep_title}"
- episodes.append(label)
-
- self._episode_cache[cache_key] = list(episodes)
- return list(episodes)
-
- def available_hosters_for(self, title: str, season: str, episode: str) -> List[str]:
- title = (title or "").strip()
- season = (season or "").strip()
- episode = (episode or "").strip()
- if not title or not season or not episode:
- return []
- if not REQUESTS_AVAILABLE or BeautifulSoup is None:
- return []
-
- self._ensure_title_index(title)
- self._ensure_stream_index(title)
- hosters = self._episode_to_hosters.get((title, season, episode), {})
- return sorted(hosters.keys(), key=lambda value: value.casefold())
-
- def set_preferred_hosters(self, hosters: List[str]) -> None:
- normalized = [hoster.strip().lower() for hoster in hosters if hoster and hoster.strip()]
- if normalized:
- self._preferred_hosters = normalized
-
- def reset_preferred_hosters(self) -> None:
- self._preferred_hosters = list(self._default_preferred_hosters)
-
- def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]:
- title = (title or "").strip()
- season = (season or "").strip()
- episode = (episode or "").strip()
- if not title or not season or not episode:
- return None
- if not REQUESTS_AVAILABLE or BeautifulSoup is None:
- return None
-
- self._ensure_title_index(title)
- self._ensure_stream_index(title)
- hosters = self._episode_to_hosters.get((title, season, episode), {})
- if not hosters:
- return None
-
- preferred = [h.casefold() for h in (self._preferred_hosters or [])]
- if preferred:
- for preferred_name in preferred:
- for actual_name, url in hosters.items():
- if actual_name.casefold() == preferred_name:
- return url
-
- # Wenn nichts passt: deterministisch den ersten.
- first_name = sorted(hosters.keys(), key=lambda value: value.casefold())[0]
- return hosters.get(first_name)
-
- def resolve_stream_link(self, link: str) -> Optional[str]:
- try:
- from resolveurl_backend import resolve as resolve_with_resolveurl
- except Exception:
- resolve_with_resolveurl = None
- if callable(resolve_with_resolveurl):
- resolved = resolve_with_resolveurl(link)
- return resolved or link
- return link
-
-
-# Alias für die automatische Plugin-Erkennung.
-Plugin = TopstreamfilmPlugin
diff --git a/dist/plugin.video.viewit/regex_patterns.py b/dist/plugin.video.viewit/regex_patterns.py
deleted file mode 100644
index c3c0b08..0000000
--- a/dist/plugin.video.viewit/regex_patterns.py
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/usr/bin/env python3
-"""Shared regex pattern constants.
-
-Keep common patterns in one place to avoid accidental double-escaping (e.g. \"\\\\d\").
-"""
-
-SEASON_EPISODE_TAG = r"S\s*(\d+)\s*E\s*(\d+)"
-SEASON_EPISODE_URL = r"/staffel-(\d+)/episode-(\d+)"
-STAFFEL_NUM_IN_URL = r"/staffel-(\d+)"
-DIGITS = r"(\d+)"
-
diff --git a/dist/plugin.video.viewit/requirements.txt b/dist/plugin.video.viewit/requirements.txt
deleted file mode 100644
index e35775c..0000000
--- a/dist/plugin.video.viewit/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-beautifulsoup4>=4.12
-requests>=2.31
diff --git a/dist/plugin.video.viewit/resolveurl_backend.py b/dist/plugin.video.viewit/resolveurl_backend.py
deleted file mode 100644
index 5b9a17a..0000000
--- a/dist/plugin.video.viewit/resolveurl_backend.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Optionales ResolveURL-Backend für das Kodi-Addon.
-
-Wenn `script.module.resolveurl` installiert ist, kann damit eine Hoster-URL
-zu einer abspielbaren Media-URL (inkl. evtl. Header-Suffix) aufgelöst werden.
-"""
-
-from __future__ import annotations
-
-from typing import Optional
-
-
-def resolve(url: str) -> Optional[str]:
- if not url:
- return None
- try:
- import resolveurl # type: ignore
- except Exception:
- return None
-
- try:
- hosted = getattr(resolveurl, "HostedMediaFile", None)
- if callable(hosted):
- hmf = hosted(url)
- valid = getattr(hmf, "valid_url", None)
- if callable(valid) and not valid():
- return None
- resolver = getattr(hmf, "resolve", None)
- if callable(resolver):
- result = resolver()
- return str(result) if result else None
- except Exception:
- pass
-
- try:
- resolve_fn = getattr(resolveurl, "resolve", None)
- if callable(resolve_fn):
- result = resolve_fn(url)
- return str(result) if result else None
- except Exception:
- return None
-
- return None
-
diff --git a/dist/plugin.video.viewit/resources/logo.png b/dist/plugin.video.viewit/resources/logo.png
deleted file mode 100644
index d11893e..0000000
Binary files a/dist/plugin.video.viewit/resources/logo.png and /dev/null differ
diff --git a/dist/plugin.video.viewit/resources/settings.xml b/dist/plugin.video.viewit/resources/settings.xml
deleted file mode 100644
index efe74a3..0000000
--- a/dist/plugin.video.viewit/resources/settings.xml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/dist/plugin.video.viewit/tmdb.py b/dist/plugin.video.viewit/tmdb.py
deleted file mode 100644
index 830e770..0000000
--- a/dist/plugin.video.viewit/tmdb.py
+++ /dev/null
@@ -1,652 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-import json
-import threading
-from typing import Callable, Dict, List, Optional, Tuple
-from urllib.parse import urlencode
-
-try: # pragma: no cover - optional dependency
- import requests
-except ImportError: # pragma: no cover
- requests = None
-
-
-TMDB_API_BASE = "https://api.themoviedb.org/3"
-TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
-_TMDB_THREAD_LOCAL = threading.local()
-
-
-def _get_tmdb_session() -> "requests.Session | None":
- """Returns a per-thread shared requests Session.
-
- We use thread-local storage because ViewIt prefetches TMDB metadata using threads.
- `requests.Session` is not guaranteed to be thread-safe, but reusing a session within
- the same thread keeps connections warm.
- """
-
- if requests is None:
- return None
- sess = getattr(_TMDB_THREAD_LOCAL, "session", None)
- if sess is None:
- sess = requests.Session()
- setattr(_TMDB_THREAD_LOCAL, "session", sess)
- return sess
-
-
-@dataclass(frozen=True)
-class TmdbCastMember:
- name: str
- role: str
- thumb: str
-
-
-@dataclass(frozen=True)
-class TmdbShowMeta:
- tmdb_id: int
- plot: str
- poster: str
- fanart: str
- rating: float
- votes: int
- cast: List[TmdbCastMember]
-
-
-def _image_url(path: str, *, size: str) -> str:
- path = (path or "").strip()
- if not path:
- return ""
- return f"{TMDB_IMAGE_BASE}/{size}{path}"
-
-
-def _fetch_credits(
- *,
- kind: str,
- tmdb_id: int,
- api_key: str,
- language: str,
- timeout: int,
- log: Callable[[str], None] | None,
- log_responses: bool,
-) -> List[TmdbCastMember]:
- if requests is None or not tmdb_id:
- return []
- params = {"api_key": api_key, "language": (language or "de-DE").strip()}
- url = f"{TMDB_API_BASE}/{kind}/{tmdb_id}/credits?{urlencode(params)}"
- if callable(log):
- log(f"TMDB GET {url}")
- try:
- response = requests.get(url, timeout=timeout)
- except Exception as exc: # pragma: no cover
- if callable(log):
- log(f"TMDB ERROR /{kind}/{{id}}/credits request_failed error={exc!r}")
- return []
- status = getattr(response, "status_code", None)
- if callable(log):
- log(f"TMDB RESPONSE /{kind}/{{id}}/credits status={status}")
- if status != 200:
- return []
- try:
- payload = response.json() or {}
- except Exception:
- return []
- if callable(log) and log_responses:
- try:
- dumped = json.dumps(payload, ensure_ascii=False)
- except Exception:
- dumped = str(payload)
- log(f"TMDB RESPONSE_BODY /{kind}/{{id}}/credits body={dumped[:2000]}")
-
- cast_payload = payload.get("cast") or []
- if callable(log):
- log(f"TMDB CREDITS /{kind}/{{id}}/credits cast={len(cast_payload)}")
- with_images: List[TmdbCastMember] = []
- without_images: List[TmdbCastMember] = []
- for entry in cast_payload:
- name = (entry.get("name") or "").strip()
- role = (entry.get("character") or "").strip()
- thumb = _image_url(entry.get("profile_path") or "", size="w185")
- if not name:
- continue
- member = TmdbCastMember(name=name, role=role, thumb=thumb)
- if thumb:
- with_images.append(member)
- else:
- without_images.append(member)
-
- # Viele Kodi-Skins zeigen bei fehlendem Thumbnail Platzhalter-Köpfe.
- # Bevorzugt daher Cast-Einträge mit Bild; nur wenn gar keine Bilder existieren,
- # geben wir Namen ohne Bild zurück.
- if with_images:
- return with_images[:30]
- return without_images[:30]
-
-
-def _parse_cast_payload(cast_payload: object) -> List[TmdbCastMember]:
- if not isinstance(cast_payload, list):
- return []
- with_images: List[TmdbCastMember] = []
- without_images: List[TmdbCastMember] = []
- for entry in cast_payload:
- if not isinstance(entry, dict):
- continue
- name = (entry.get("name") or "").strip()
- role = (entry.get("character") or "").strip()
- thumb = _image_url(entry.get("profile_path") or "", size="w185")
- if not name:
- continue
- member = TmdbCastMember(name=name, role=role, thumb=thumb)
- if thumb:
- with_images.append(member)
- else:
- without_images.append(member)
- if with_images:
- return with_images[:30]
- return without_images[:30]
-
-
-def _tmdb_get_json(
- *,
- url: str,
- timeout: int,
- log: Callable[[str], None] | None,
- log_responses: bool,
- session: "requests.Session | None" = None,
-) -> Tuple[int | None, object | None, str]:
- """Fetches TMDB JSON with optional shared session.
-
- Returns: (status_code, payload_or_none, body_text_or_empty)
- """
-
- if requests is None:
- return None, None, ""
- if callable(log):
- log(f"TMDB GET {url}")
- sess = session or _get_tmdb_session() or requests.Session()
- try:
- response = sess.get(url, timeout=timeout)
- except Exception as exc: # pragma: no cover
- if callable(log):
- log(f"TMDB ERROR request_failed url={url} error={exc!r}")
- return None, None, ""
-
- status = getattr(response, "status_code", None)
- payload: object | None = None
- body_text = ""
- try:
- payload = response.json()
- except Exception:
- try:
- body_text = (response.text or "").strip()
- except Exception:
- body_text = ""
-
- if callable(log):
- log(f"TMDB RESPONSE status={status} url={url}")
- if log_responses:
- if payload is not None:
- try:
- dumped = json.dumps(payload, ensure_ascii=False)
- except Exception:
- dumped = str(payload)
- log(f"TMDB RESPONSE_BODY url={url} body={dumped[:2000]}")
- elif body_text:
- log(f"TMDB RESPONSE_BODY url={url} body={body_text[:2000]}")
- return status, payload, body_text
-
-
-def fetch_tv_episode_credits(
- *,
- tmdb_id: int,
- season_number: int,
- episode_number: int,
- api_key: str,
- language: str = "de-DE",
- timeout: int = 15,
- log: Callable[[str], None] | None = None,
- log_responses: bool = False,
-) -> List[TmdbCastMember]:
- """Lädt Cast für eine konkrete Episode (/tv/{id}/season/{n}/episode/{e}/credits)."""
- if requests is None:
- return []
- api_key = (api_key or "").strip()
- if not api_key or not tmdb_id:
- return []
- params = {"api_key": api_key, "language": (language or "de-DE").strip()}
- url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}/episode/{episode_number}/credits?{urlencode(params)}"
- if callable(log):
- log(f"TMDB GET {url}")
- try:
- response = requests.get(url, timeout=timeout)
- except Exception as exc: # pragma: no cover
- if callable(log):
- log(f"TMDB ERROR /tv/{{id}}/season/{{n}}/episode/{{e}}/credits request_failed error={exc!r}")
- return []
- status = getattr(response, "status_code", None)
- if callable(log):
- log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}}/episode/{{e}}/credits status={status}")
- if status != 200:
- return []
- try:
- payload = response.json() or {}
- except Exception:
- return []
- if callable(log) and log_responses:
- try:
- dumped = json.dumps(payload, ensure_ascii=False)
- except Exception:
- dumped = str(payload)
- log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}}/episode/{{e}}/credits body={dumped[:2000]}")
-
- cast_payload = payload.get("cast") or []
- if callable(log):
- log(f"TMDB CREDITS /tv/{{id}}/season/{{n}}/episode/{{e}}/credits cast={len(cast_payload)}")
- with_images: List[TmdbCastMember] = []
- without_images: List[TmdbCastMember] = []
- for entry in cast_payload:
- name = (entry.get("name") or "").strip()
- role = (entry.get("character") or "").strip()
- thumb = _image_url(entry.get("profile_path") or "", size="w185")
- if not name:
- continue
- member = TmdbCastMember(name=name, role=role, thumb=thumb)
- if thumb:
- with_images.append(member)
- else:
- without_images.append(member)
- if with_images:
- return with_images[:30]
- return without_images[:30]
-
-
-def lookup_tv_show(
- *,
- title: str,
- api_key: str,
- language: str = "de-DE",
- timeout: int = 15,
- log: Callable[[str], None] | None = None,
- log_responses: bool = False,
- include_cast: bool = False,
-) -> Optional[TmdbShowMeta]:
- """Sucht eine TV-Show bei TMDB und liefert Plot + Poster-URL (wenn vorhanden)."""
- if requests is None:
- return None
- api_key = (api_key or "").strip()
- if not api_key:
- return None
- query = (title or "").strip()
- if not query:
- return None
-
- params = {
- "api_key": api_key,
- "language": (language or "de-DE").strip(),
- "query": query,
- "include_adult": "false",
- "page": "1",
- }
- url = f"{TMDB_API_BASE}/search/tv?{urlencode(params)}"
- status, payload, body_text = _tmdb_get_json(
- url=url,
- timeout=timeout,
- log=log,
- log_responses=log_responses,
- )
- results = (payload or {}).get("results") if isinstance(payload, dict) else []
- results = results or []
- if callable(log):
- log(f"TMDB RESPONSE /search/tv status={status} results={len(results)}")
- if log_responses and payload is None and body_text:
- log(f"TMDB RESPONSE_BODY /search/tv body={body_text[:2000]}")
-
- if status != 200:
- return None
- if not results:
- return None
-
- normalized_query = query.casefold()
- best = None
- for candidate in results:
- name = (candidate.get("name") or "").casefold()
- original_name = (candidate.get("original_name") or "").casefold()
- if name == normalized_query or original_name == normalized_query:
- best = candidate
- break
- if best is None:
- best = results[0]
-
- tmdb_id = int(best.get("id") or 0)
- plot = (best.get("overview") or "").strip()
- poster = _image_url(best.get("poster_path") or "", size="w342")
- fanart = _image_url(best.get("backdrop_path") or "", size="w780")
- try:
- rating = float(best.get("vote_average") or 0.0)
- except Exception:
- rating = 0.0
- try:
- votes = int(best.get("vote_count") or 0)
- except Exception:
- votes = 0
- if not tmdb_id:
- return None
- cast: List[TmdbCastMember] = []
- if include_cast and tmdb_id:
- detail_params = {
- "api_key": api_key,
- "language": (language or "de-DE").strip(),
- "append_to_response": "credits",
- }
- detail_url = f"{TMDB_API_BASE}/tv/{tmdb_id}?{urlencode(detail_params)}"
- d_status, d_payload, d_body = _tmdb_get_json(
- url=detail_url,
- timeout=timeout,
- log=log,
- log_responses=log_responses,
- )
- if callable(log):
- log(f"TMDB RESPONSE /tv/{{id}} status={d_status}")
- if log_responses and d_payload is None and d_body:
- log(f"TMDB RESPONSE_BODY /tv/{{id}} body={d_body[:2000]}")
- if d_status == 200 and isinstance(d_payload, dict):
- credits = d_payload.get("credits") or {}
- cast = _parse_cast_payload((credits or {}).get("cast"))
- if not plot and not poster and not fanart and not rating and not votes and not cast:
- return None
- return TmdbShowMeta(
- tmdb_id=tmdb_id,
- plot=plot,
- poster=poster,
- fanart=fanart,
- rating=rating,
- votes=votes,
- cast=cast,
- )
-
-
-@dataclass(frozen=True)
-class TmdbMovieMeta:
- tmdb_id: int
- plot: str
- poster: str
- fanart: str
- runtime_minutes: int
- rating: float
- votes: int
- cast: List[TmdbCastMember]
-
-
-def _fetch_movie_details(
- *,
- tmdb_id: int,
- api_key: str,
- language: str,
- timeout: int,
- log: Callable[[str], None] | None,
- log_responses: bool,
- include_cast: bool,
-) -> Tuple[int, List[TmdbCastMember]]:
- """Fetches /movie/{id} and (optionally) bundles credits via append_to_response=credits."""
- if requests is None or not tmdb_id:
- return 0, []
- api_key = (api_key or "").strip()
- if not api_key:
- return 0, []
- params: Dict[str, str] = {
- "api_key": api_key,
- "language": (language or "de-DE").strip(),
- }
- if include_cast:
- params["append_to_response"] = "credits"
- url = f"{TMDB_API_BASE}/movie/{tmdb_id}?{urlencode(params)}"
- status, payload, body_text = _tmdb_get_json(url=url, timeout=timeout, log=log, log_responses=log_responses)
- if callable(log):
- log(f"TMDB RESPONSE /movie/{{id}} status={status}")
- if log_responses and payload is None and body_text:
- log(f"TMDB RESPONSE_BODY /movie/{{id}} body={body_text[:2000]}")
- if status != 200 or not isinstance(payload, dict):
- return 0, []
- try:
- runtime = int(payload.get("runtime") or 0)
- except Exception:
- runtime = 0
- cast: List[TmdbCastMember] = []
- if include_cast:
- credits = payload.get("credits") or {}
- cast = _parse_cast_payload((credits or {}).get("cast"))
- return runtime, cast
-
-
-def lookup_movie(
- *,
- title: str,
- api_key: str,
- language: str = "de-DE",
- timeout: int = 15,
- log: Callable[[str], None] | None = None,
- log_responses: bool = False,
- include_cast: bool = False,
-) -> Optional[TmdbMovieMeta]:
- """Sucht einen Film bei TMDB und liefert Plot + Poster-URL (wenn vorhanden)."""
- if requests is None:
- return None
- api_key = (api_key or "").strip()
- if not api_key:
- return None
- query = (title or "").strip()
- if not query:
- return None
-
- params = {
- "api_key": api_key,
- "language": (language or "de-DE").strip(),
- "query": query,
- "include_adult": "false",
- "page": "1",
- }
- url = f"{TMDB_API_BASE}/search/movie?{urlencode(params)}"
- status, payload, body_text = _tmdb_get_json(
- url=url,
- timeout=timeout,
- log=log,
- log_responses=log_responses,
- )
- results = (payload or {}).get("results") if isinstance(payload, dict) else []
- results = results or []
- if callable(log):
- log(f"TMDB RESPONSE /search/movie status={status} results={len(results)}")
- if log_responses and payload is None and body_text:
- log(f"TMDB RESPONSE_BODY /search/movie body={body_text[:2000]}")
-
- if status != 200:
- return None
- if not results:
- return None
-
- normalized_query = query.casefold()
- best = None
- for candidate in results:
- name = (candidate.get("title") or "").casefold()
- original_name = (candidate.get("original_title") or "").casefold()
- if name == normalized_query or original_name == normalized_query:
- best = candidate
- break
- if best is None:
- best = results[0]
-
- tmdb_id = int(best.get("id") or 0)
- plot = (best.get("overview") or "").strip()
- poster = _image_url(best.get("poster_path") or "", size="w342")
- fanart = _image_url(best.get("backdrop_path") or "", size="w780")
- runtime_minutes = 0
- try:
- rating = float(best.get("vote_average") or 0.0)
- except Exception:
- rating = 0.0
- try:
- votes = int(best.get("vote_count") or 0)
- except Exception:
- votes = 0
- if not tmdb_id:
- return None
- cast: List[TmdbCastMember] = []
- runtime_minutes, cast = _fetch_movie_details(
- tmdb_id=tmdb_id,
- api_key=api_key,
- language=language,
- timeout=timeout,
- log=log,
- log_responses=log_responses,
- include_cast=include_cast,
- )
- if not plot and not poster and not fanart and not rating and not votes and not cast:
- return None
- return TmdbMovieMeta(
- tmdb_id=tmdb_id,
- plot=plot,
- poster=poster,
- fanart=fanart,
- runtime_minutes=runtime_minutes,
- rating=rating,
- votes=votes,
- cast=cast,
- )
-
-
-@dataclass(frozen=True)
-class TmdbEpisodeMeta:
- plot: str
- thumb: str
- runtime_minutes: int
-
-
-@dataclass(frozen=True)
-class TmdbSeasonMeta:
- plot: str
- poster: str
-
-
-def lookup_tv_season_summary(
- *,
- tmdb_id: int,
- season_number: int,
- api_key: str,
- language: str = "de-DE",
- timeout: int = 15,
- log: Callable[[str], None] | None = None,
- log_responses: bool = False,
-) -> Optional[TmdbSeasonMeta]:
- """Lädt Staffel-Meta (Plot + Poster)."""
- if requests is None:
- return None
-
- api_key = (api_key or "").strip()
- if not api_key or not tmdb_id:
- return None
-
- params = {"api_key": api_key, "language": (language or "de-DE").strip()}
- url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}?{urlencode(params)}"
- if callable(log):
- log(f"TMDB GET {url}")
- try:
- response = requests.get(url, timeout=timeout)
- except Exception:
- return None
- status = getattr(response, "status_code", None)
- if callable(log):
- log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}} status={status}")
- if status != 200:
- return None
- try:
- payload = response.json() or {}
- except Exception:
- return None
- if callable(log) and log_responses:
- try:
- dumped = json.dumps(payload, ensure_ascii=False)
- except Exception:
- dumped = str(payload)
- log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}} body={dumped[:2000]}")
-
- plot = (payload.get("overview") or "").strip()
- poster_path = (payload.get("poster_path") or "").strip()
- poster = f"{TMDB_IMAGE_BASE}/w342{poster_path}" if poster_path else ""
- if not plot and not poster:
- return None
- return TmdbSeasonMeta(plot=plot, poster=poster)
-
-
-def lookup_tv_season(
- *,
- tmdb_id: int,
- season_number: int,
- api_key: str,
- language: str = "de-DE",
- timeout: int = 15,
- log: Callable[[str], None] | None = None,
- log_responses: bool = False,
-) -> Optional[Dict[int, TmdbEpisodeMeta]]:
- """Lädt Episoden-Meta für eine Staffel: episode_number -> (plot, thumb)."""
- if requests is None:
- return None
- api_key = (api_key or "").strip()
- if not api_key or not tmdb_id or season_number is None:
- return None
- params = {"api_key": api_key, "language": (language or "de-DE").strip()}
- url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}?{urlencode(params)}"
- if callable(log):
- log(f"TMDB GET {url}")
- try:
- response = requests.get(url, timeout=timeout)
- except Exception as exc: # pragma: no cover
- if callable(log):
- log(f"TMDB ERROR /tv/{{id}}/season/{{n}} request_failed error={exc!r}")
- return None
-
- status = getattr(response, "status_code", None)
- payload = None
- body_text = ""
- try:
- payload = response.json() or {}
- except Exception:
- try:
- body_text = (response.text or "").strip()
- except Exception:
- body_text = ""
-
- episodes = (payload or {}).get("episodes") or []
- if callable(log):
- log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}} status={status} episodes={len(episodes)}")
- if log_responses:
- if payload is not None:
- try:
- dumped = json.dumps(payload, ensure_ascii=False)
- except Exception:
- dumped = str(payload)
- log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}} body={dumped[:2000]}")
- elif body_text:
- log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}} body={body_text[:2000]}")
-
- if status != 200 or not episodes:
- return None
-
- result: Dict[int, TmdbEpisodeMeta] = {}
- for entry in episodes:
- try:
- ep_number = int(entry.get("episode_number") or 0)
- except Exception:
- continue
- if not ep_number:
- continue
- plot = (entry.get("overview") or "").strip()
- runtime_minutes = 0
- try:
- runtime_minutes = int(entry.get("runtime") or 0)
- except Exception:
- runtime_minutes = 0
- still_path = (entry.get("still_path") or "").strip()
- thumb = f"{TMDB_IMAGE_BASE}/w300{still_path}" if still_path else ""
- if not plot and not thumb and not runtime_minutes:
- continue
- result[ep_number] = TmdbEpisodeMeta(plot=plot, thumb=thumb, runtime_minutes=runtime_minutes)
- return result or None