diff --git a/script.easymovie/LICENSE.txt b/script.easymovie/LICENSE.txt new file mode 100644 index 000000000..10926e87f --- /dev/null +++ b/script.easymovie/LICENSE.txt @@ -0,0 +1,675 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + 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 . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/script.easymovie/addon.xml b/script.easymovie/addon.xml new file mode 100644 index 000000000..0be686795 --- /dev/null +++ b/script.easymovie/addon.xml @@ -0,0 +1,31 @@ + + + + + + + executable + + + + Simplify movie night + EasyMovie helps you pick movies for movie night. Answer a few questions (genre, rating, runtime) and get a curated random selection from your library. Supports movie set awareness, multiple viewing modes, and playlist generation. + + icon.png + + GPL-3.0-only + https://forum.kodi.tv/showthread.php?tid=385063 + https://rouzax.github.io/script.easymovie/ + https://github.com/Rouzax/script.easymovie + all + +v1.0.0 (2026-03-24) +- Filter wizard narrows your library by genre, rating, runtime, year, and score +- Five browse views: Showcase, Card List, Posters, Big Screen, Split View +- Playlist mode builds movie marathons with resume support +- Movie set awareness with continuation prompts and earlier-movie warnings +- Four color themes: Golden Hour, Ultraviolet, Ember, Nightfall +- Clone support for multiple instances with independent settings + + + diff --git a/script.easymovie/changelog.txt b/script.easymovie/changelog.txt new file mode 100644 index 000000000..8ca4ffb54 --- /dev/null +++ b/script.easymovie/changelog.txt @@ -0,0 +1,32 @@ +v1.0.0 (2026-03-24) +-------------------------- +New Features: + - Filter Wizard: step-by-step filter flow for genre, watched status, age rating, + runtime, time period, and score. Each filter configurable as Ask, Pre-set, or Skip. + Supports genre ignore/select with OR/AND matching, decade browsing, recency ranges, + and cumulative movie counts per option. + - Browse Mode: visual results screen with 5 view styles — Showcase (horizontal + filmstrip carousel, default), Card List, Posters, Big Screen, and Split View. + Includes Re-roll, Surprise Me, Play Full Set, and configurable result count (1–50). + - Playlist Mode: automatic movie marathon generation with configurable length (1–20), + sort options, partial resume prioritization, and seek-to-resume-point. + - Movie Set Awareness: detects Kodi movie collections and suggests the first unwatched + entry instead of a random one. Shows set name and position in browse views. + - Set Continuation: after finishing a collection movie, prompts to watch the next one + with a configurable countdown timer and default action. + - Earlier Movie Warning: alerts when about to watch a set movie while an earlier + entry is unwatched, via background service monitoring. + - Smart Re-suggestion: tracks recently suggested movies and avoids repeating them + within a configurable cooldown window (4–72 hours). + - Movie Pool Filtering: limit the movie pool to any Kodi smart playlist. + - Clone Support: create multiple EasyMovie instances with independent settings. + Includes mandatory version check and one-click update on launch. + - 4 Color Themes: Golden Hour, Ultraviolet, Ember, Nightfall — applied to all + views and dialogs. Live preview cycling via T key / blue remote button. + - Custom Icons: set a custom addon icon per instance, persisted across upgrades. + - In-progress Check: on launch, offers to resume a partially watched movie. + - Wizard Memory: remembers last wizard answers for quick repeat sessions. + - Structured Logging: separate log file with rotating backups, structured key=value + data, and per-module loggers. + - GitHub Pages landing page, wiki documentation, and Kodi forum thread. + - CI validation workflow (syntax, pyflakes, pyright, kodi-addon-checker). diff --git a/script.easymovie/default.py b/script.easymovie/default.py new file mode 100644 index 000000000..0e5b48728 --- /dev/null +++ b/script.easymovie/default.py @@ -0,0 +1,31 @@ +""" +EasyMovie entry point. + +Dispatches to the main UI flow or handles special +command-line arguments (selector, clone, set_icon). + +Logging: + Logger: 'default' + Key events: + - launch.crash (ERROR): Unhandled error caught at top level + See LOGGING.md for full guidelines. +""" +from resources.lib.ui.main import main, _handle_entry_args + +try: + if not _handle_entry_args("script.easymovie"): + main() +except SystemExit: + pass +except Exception: + try: + from resources.lib.utils import get_logger + log = get_logger('default') + log.exception("Unhandled error in EasyMovie", event="launch.crash") + except Exception: + import traceback + import xbmc + xbmc.log( + f"[EasyMovie] Unhandled error: {traceback.format_exc()}", + xbmc.LOGERROR, + ) diff --git a/script.easymovie/icon.png b/script.easymovie/icon.png new file mode 100644 index 000000000..1c72a8fe6 Binary files /dev/null and b/script.easymovie/icon.png differ diff --git a/script.easymovie/icon_default.png b/script.easymovie/icon_default.png new file mode 100644 index 000000000..1c72a8fe6 Binary files /dev/null and b/script.easymovie/icon_default.png differ diff --git a/script.easymovie/resources/__init__.py b/script.easymovie/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/script.easymovie/resources/addon_clone.xml b/script.easymovie/resources/addon_clone.xml new file mode 100644 index 000000000..682c43c0c --- /dev/null +++ b/script.easymovie/resources/addon_clone.xml @@ -0,0 +1,28 @@ + + + + + + + + executable + + + + COMBNAME + This is a cloned version of the EasyMovie front-end with independent settings. + + all + GPL-3.0-only + https://github.com/Rouzax/script.easymovie + https://github.com/Rouzax/script.easymovie + + icon.png + fanart.jpg + + + diff --git a/script.easymovie/resources/clone.py b/script.easymovie/resources/clone.py new file mode 100644 index 000000000..3aea95563 --- /dev/null +++ b/script.easymovie/resources/clone.py @@ -0,0 +1,300 @@ +""" +EasyMovie Clone Creation. + +Creates independent "clones" of EasyMovie with separate settings. +Useful for pre-configured instances like "Kids Movies", "Action Night", etc. + +Clone Lifecycle: + 1. Creation: + - User triggers via "Create clone addon..." in EasyMovie settings + - Confirmation dialog explains what cloning does + - Keyboard dialog prompts for a name + - Progress dialog shows each step + + 2. File Structure: + - Copies entire addon to temp folder first (atomic) + - Removes clone-only files (clone.py, templates) + - Replaces addon.xml and settings.xml with clone versions + - Updates addon ID in Python scripts, skin XMLs, language files + + 3. Registration: + - UpdateLocalAddons + disable/enable cycle + - Clone appears in Video Addons with the user's name + + 4. Usage: + - Each clone has independent settings (in userdata) + - Clones are updated via update_clone.py when parent updates + +Logging: + Logger: 'clone' + Key events: + - clone.create (INFO): Clone created successfully + - clone.fail (ERROR): Clone creation failed + - clone.name_fallback (WARNING): Name sanitized to fallback + See LOGGING.md for full guidelines. +""" +import os +import shutil +from typing import List, Tuple +from xml.etree import ElementTree as ET + +import xbmc +import xbmcaddon +import xbmcgui +import xbmcvfs + +from resources.lib.constants import ADDON_ID, ADDON_NAME +from resources.lib.utils import get_logger, lang +from resources.lib.ui.dialogs import show_confirm_dialog + +log = get_logger('clone') + + +def _sanitize_filename(dirty_string: str) -> str: + """Sanitize a string for use as an addon ID component.""" + import string as string_module + dirty_string = dirty_string.strip() + valid_chars = f"-_.(){string_module.ascii_letters}{string_module.digits} " + sanitized = ''.join(c for c in dirty_string if c in valid_chars) + sanitized = sanitized.replace(' ', '_').lower() + return sanitized + + +def _replace_in_file(filepath: str, replacements: List[Tuple[str, str]]) -> None: + """Perform string replacements in a file.""" + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + for old, new in replacements: + content = content.replace(old, new) + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + + +def create_clone() -> None: + """Create a clone of the EasyMovie addon.""" + dialog = xbmcgui.Dialog() + + # Pre-creation confirmation + confirmed = show_confirm_dialog( + ADDON_NAME, + lang(32700) + '\n' + lang(32701) + '\n' + lang(32702), + yes_label=lang(32716), # "Continue" + no_label=lang(32301), # "Cancel" + ) + if not confirmed: + return + + # Get clone name from user + keyboard = xbmc.Keyboard(lang(32703)) # "EasyMovie - " + keyboard.doModal() + + if not keyboard.isConfirmed(): + return + + clone_name = keyboard.getText().strip() + if not clone_name: + clone_name = 'Clone' + + sanitized = _sanitize_filename(clone_name) + + # Fallback if sanitization yields empty/non-alphanumeric + if not sanitized or not any(c.isalnum() for c in sanitized): + sanitized = 'clone' + log.warning("Clone name sanitized to fallback", + event="clone.name_fallback", original=clone_name) + + clone_id = f"script.easymovie.{sanitized}" + + # Get parent addon info + addon = xbmcaddon.Addon(ADDON_ID) + addon_path = addon.getAddonInfo('path') + parent_version = addon.getAddonInfo('version') + addons_dir = xbmcvfs.translatePath('special://home/addons') + clone_path = os.path.join(addons_dir, clone_id) + + # Prevent overwriting parent addon + if clone_id == ADDON_ID: + log.error("Invalid clone name would overwrite parent", + event="clone.fail", clone_id=clone_id) + dialog.ok(ADDON_NAME, lang(32704)) + addon.openSettings() + return + + # Check if clone already exists + if os.path.isdir(clone_path): + log.warning("Clone name already in use", + event="clone.fail", clone_id=clone_id) + dialog.ok(ADDON_NAME, lang(32704)) + addon.openSettings() + return + + # Use temp folder for atomic operation + temp_base = xbmcvfs.translatePath('special://temp/') + temp_path = os.path.join(temp_base, f'easymovie_clone_{clone_id}') + + # Show modal progress dialog + progress = xbmcgui.DialogProgress() + progress.create(ADDON_NAME, "Creating clone...") + + try: + # Clean up any leftover temp folder + if os.path.isdir(temp_path): + shutil.rmtree(temp_path, ignore_errors=True) + + progress.update(10, "Copying addon files...") + ignore = shutil.ignore_patterns( + '.pyc', '.git*', '__pycache__', 'CVS', '.svn', + 'docs', 'tests', '.claude*', '.mcp.json', + 'CLAUDE.md', 'LOGGING.md', 'CONTRIBUTING.md', 'README.md', + 'pyrightconfig.json', '.pyflakes', '.ruff_cache', + '.pytest_cache', 'conftest.py', 'pytest.ini', + '_temp', '.worktrees', + ) + shutil.copytree(addon_path, temp_path, ignore=ignore) + + # Restore default icon + default_icon = os.path.join(temp_path, 'icon_default.png') + if os.path.isfile(default_icon): + shutil.copy2(default_icon, os.path.join(temp_path, 'icon.png')) + + progress.update(25, "Configuring clone...") + # Remove original addon.xml and settings.xml (will be replaced by templates) + os.remove(os.path.join(temp_path, 'addon.xml')) + os.remove(os.path.join(temp_path, 'resources', 'settings.xml')) + + # Move clone templates into place + addon_xml = os.path.join(temp_path, 'addon.xml') + shutil.move( + os.path.join(temp_path, 'resources', 'addon_clone.xml'), + addon_xml, + ) + shutil.move( + os.path.join(temp_path, 'resources', 'settings_clone.xml'), + os.path.join(temp_path, 'resources', 'settings.xml'), + ) + + # Remove clone-only files (clones don't run background services) + for remove_file in [ + 'resources/clone.py', 'resources/update_clone.py', 'service.py', + ]: + path = os.path.join(temp_path, remove_file) + if os.path.exists(path): + os.remove(path) + + progress.update(35, "Updating addon metadata...") + # Update addon.xml via ElementTree (overwrites template tokens) + tree = ET.parse(addon_xml) + root = tree.getroot() + root.set('id', clone_id) + root.set('name', clone_name) + root.set('version', parent_version) + summary_elem = tree.find('.//summary') + if summary_elem is not None: + summary_elem.text = clone_name + tree.write(addon_xml, encoding='unicode', xml_declaration=True) + + progress.update(45, "Updating settings...") + settings_file = os.path.join(temp_path, 'resources', 'settings.xml') + _replace_in_file(settings_file, [('script.easymovie', clone_id)]) + + progress.update(55, "Updating language files...") + language_dir = os.path.join(temp_path, 'resources', 'language') + for lang_folder in os.listdir(language_dir): + strings_file = os.path.join(language_dir, lang_folder, 'strings.po') + if os.path.isfile(strings_file): + _replace_in_file(strings_file, [ + ('# Addon Name: EasyMovie', f'# Addon Name: {clone_name}'), + ('# Addon id: script.easymovie', f'# Addon id: {clone_id}'), + ]) + + progress.update(65, "Updating scripts...") + # Rewrite default.py with clone addon ID + default_py = os.path.join(temp_path, 'default.py') + with open(default_py, 'w', encoding='utf-8') as f: + f.write( + '"""EasyMovie clone entry point."""\n' + 'from resources.lib.ui.main import main, _handle_entry_args\n' + '\n' + 'try:\n' + f' if not _handle_entry_args("{clone_id}"):\n' + f' main(addon_id="{clone_id}")\n' + 'except SystemExit:\n' + ' pass\n' + 'except Exception:\n' + ' try:\n' + ' from resources.lib.utils import get_logger\n' + " log = get_logger('default')\n" + ' log.exception("Unhandled error in EasyMovie clone", event="launch.crash")\n' + ' except Exception:\n' + ' import traceback\n' + ' import xbmc\n' + ' xbmc.log(\n' + ' f"[EasyMovie] Unhandled error: {traceback.format_exc()}",\n' + ' xbmc.LOGERROR,\n' + ' )\n' + ) + + # Update selector.py addon ID references + selector_py = os.path.join(temp_path, 'resources', 'selector.py') + if os.path.isfile(selector_py): + _replace_in_file(selector_py, [('script.easymovie', clone_id)]) + + progress.update(75, "Updating skins...") + skin_dir = os.path.join( + temp_path, 'resources', 'skins', 'Default', '1080i' + ) + for filename in os.listdir(skin_dir): + if filename.endswith('.xml'): + skin_file = os.path.join(skin_dir, filename) + _replace_in_file(skin_file, [ + ('$ADDON[script.easymovie ', f'$ADDON[{clone_id} '), + ]) + + progress.update(85, "Installing clone...") + shutil.move(temp_path, clone_path) + + except Exception: + log.exception("Clone creation failed", event="clone.fail", + clone_id=clone_id) + if os.path.isdir(temp_path): + shutil.rmtree(temp_path, ignore_errors=True) + progress.close() + dialog.ok(ADDON_NAME, lang(32707) + '\n' + lang(32708)) + return + + # Register with Kodi + try: + progress.update(90, "Registering with Kodi...") + xbmc.executebuiltin('UpdateLocalAddons') + xbmc.sleep(3000) + + progress.update(95, "Enabling clone...") + xbmc.executeJSONRPC( + '{"jsonrpc":"2.0","method":"Addons.SetAddonEnabled",' + f'"id":1,"params":{{"addonid":"{clone_id}","enabled":false}}}}' + ) + xbmc.sleep(500) + xbmc.executeJSONRPC( + '{"jsonrpc":"2.0","method":"Addons.SetAddonEnabled",' + f'"id":1,"params":{{"addonid":"{clone_id}","enabled":true}}}}' + ) + xbmc.sleep(1000) + + progress.update(100, "Complete!") + xbmc.sleep(500) + except Exception: + log.warning("Addon registration may have failed", + event="clone.fail", clone_id=clone_id) + finally: + progress.close() + + log.info("Clone created successfully", event="clone.create", + clone_id=clone_id, name=clone_name, version=parent_version) + + dialog.ok(ADDON_NAME, lang(32705) + '\n' + lang(32706)) + + +if __name__ == '__main__': + log.info("Clone creation started", event="clone.start") + create_clone() + log.info("Clone creation completed", event="clone.complete") diff --git a/script.easymovie/resources/dialog_preview.py b/script.easymovie/resources/dialog_preview.py new file mode 100644 index 000000000..b3eb8effb --- /dev/null +++ b/script.easymovie/resources/dialog_preview.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Dialog Preview Script — quickly cycle through all custom dialogs. + +Usage from Kodi: + RunScript(script.easymovie,dialog_preview) + RunScript(script.easymovie,dialog_preview,script.easymovie.kids) + +Or from the Kodi debug console / JSON-RPC: + {"jsonrpc":"2.0","method":"Addons.ExecuteAddon", + "params":{"addonid":"script.easymovie","params":["dialog_preview"]},"id":1} + +The optional third argument overrides the addon ID, so dialogs use +that addon's name, theme, and skin path (useful for testing clones). +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Union, cast + +import xbmcgui +import xbmcaddon + +dialog = xbmcgui.Dialog() + +# Resolved at init() time — overridable via Main(override_addon_id) +addon_id = "" +addon_name = "" +script_path = "" +_notify_title = "" + +# Module-level cache so "All Dialogs" doesn't re-query +_cached_movies: Optional[List[Dict[str, Any]]] = None + + +def _fetch_preview_movies(count: int = 20) -> List[Dict[str, Any]]: + """Fetch random movies with art from the library. + + Returns cached result on subsequent calls. + """ + global _cached_movies + if _cached_movies is not None: + return _cached_movies + + from resources.lib.utils import json_query + + query = { + "jsonrpc": "2.0", + "method": "VideoLibrary.GetMovies", + "params": { + "properties": [ + "title", "genre", "year", "rating", "runtime", + "mpaa", "set", "setid", "playcount", "dateadded", + "plot", "art", "file", "resume", "lastplayed", + ], + "sort": {"method": "random"}, + "limits": {"end": count}, + }, + "id": 1, + } + result = json_query(query) + movies = result.get("movies", []) + _cached_movies = movies + return movies + + +def _find_set_movie(movies: List[Dict[str, Any]]) -> Dict[str, Any]: + """Find a movie that belongs to a set, or fall back to first movie.""" + for m in movies: + if m.get("set") and m.get("setid", 0) > 0: + return m + if movies: + # Fake set info on first movie + fake = dict(movies[0]) + fake["set"] = "Preview Collection" + fake["setid"] = 999 + return fake + return { + "movieid": 0, "title": "Preview Movie", "year": 2024, + "genre": ["Drama"], "rating": 7.5, "runtime": 7200, + "mpaa": "PG-13", "plot": "A preview movie.", + "set": "Preview Collection", "setid": 999, + "art": {}, "playcount": 0, + } + + +# Module-level cache for set pair +_cached_set_pair: Optional[Dict[str, Any]] = None + + +def _fetch_set_pair() -> Dict[str, Any]: + """Fetch two consecutive movies from the same set. + + Returns dict with keys: finished, next_movie, set_name. + Uses cached result on subsequent calls. + """ + global _cached_set_pair + if _cached_set_pair is not None: + return _cached_set_pair + + from resources.lib.utils import json_query + from resources.lib.data.queries import ( + get_all_movie_sets_query, + get_movie_set_details_query, + ) + + # Find a set with at least 2 movies + sets_result = json_query(get_all_movie_sets_query()) + for movie_set in sets_result.get("sets", []): + set_id = movie_set.get("setid", 0) + if not set_id: + continue + details = json_query(get_movie_set_details_query(set_id)) + set_details = details.get("setdetails", details) + set_movies = set_details.get("movies", []) + if len(set_movies) >= 2: + _cached_set_pair = { + "finished": set_movies[0], + "next_movie": set_movies[1], + "set_name": set_details.get("title", movie_set.get("title", "")), + } + return _cached_set_pair + + # Fallback: no sets with 2+ movies + _cached_set_pair = { + "finished": {"title": "The Dark Knight", "year": 2008, "art": {}}, + "next_movie": {"title": "The Dark Knight Rises", "year": 2012, "art": {}}, + "set_name": "The Dark Knight Collection", + } + return _cached_set_pair + + +def preview_confirm() -> None: + """Show the themed ConfirmDialog.""" + from resources.lib.ui.dialogs import show_confirm_dialog + result = show_confirm_dialog( + "Confirm Dialog Preview", + "This is a test message.\nDo you want to continue?", + yes_label="Accept", + no_label="Decline", + addon_id=addon_id, + ) + dialog.notification(_notify_title, "Confirm result: %s" % result) + + +def preview_confirm_single() -> None: + """Show the themed ConfirmDialog in OK-only mode.""" + from resources.lib.ui.dialogs import show_confirm_dialog + result = show_confirm_dialog( + "Confirm (OK Only) Preview", + "This is an information message.\nOnly an OK button is shown.", + yes_label="OK", + no_label="", + addon_id=addon_id, + ) + dialog.notification(_notify_title, "OK-only result: %s" % result) + + +def preview_select_single() -> None: + """Show the themed SelectDialog in single-select mode.""" + from resources.lib.ui.dialogs import show_select_dialog + items = [ + "First Option", + "Second Option", + "Third Option", + "Fourth Option", + "Fifth Option", + "Sixth Option", + "Seventh Option", + "Eighth Option", + ] + result = show_select_dialog( + "Single Select Preview", items, + multi_select=False, addon_id=addon_id, + ) + dialog.notification(_notify_title, "Selected: %s" % result) + + +def preview_select_multi() -> None: + """Show the themed SelectDialog in multi-select mode.""" + from resources.lib.ui.dialogs import show_select_dialog + items = [ + "Action", + "Comedy", + "Drama", + "Horror", + "Sci-Fi", + "Thriller", + "Animation", + "Documentary", + ] + result = show_select_dialog( + "Multi Select Preview", items, + multi_select=True, preselected=[1, 3, 5], + addon_id=addon_id, + ) + dialog.notification(_notify_title, "Selected: %s" % result) + + +def preview_browse() -> None: + """Show the BrowseWindow — lets user pick which view style to preview.""" + movies = _fetch_preview_movies() + if not movies: + dialog.ok(_notify_title, + "No movies found in the library.\n" + "Browse preview requires a populated movie library.") + return + + from resources.lib.ui.browse_window import BrowseWindow, VIEW_XML_MAP + from resources.lib.constants import ( + VIEW_SHOWCASE, VIEW_CARD_LIST, VIEW_POSTERS, + VIEW_BIG_SCREEN, VIEW_SPLIT_VIEW, + ) + + view_names = [ + "Showcase", + "Card List", + "Posters", + "Big Screen", + "Split View", + ] + view_values = [ + VIEW_SHOWCASE, VIEW_CARD_LIST, VIEW_POSTERS, + VIEW_BIG_SCREEN, VIEW_SPLIT_VIEW, + ] + + choice = dialog.select("Browse View Style", + cast(List[Union[str, xbmcgui.ListItem]], view_names)) + if choice < 0: + return + + addon = xbmcaddon.Addon(addon_id) + try: + theme_index = int(addon.getSetting('theme') or '0') + except (ValueError, TypeError): + theme_index = 0 + + xml_file = VIEW_XML_MAP.get(view_values[choice], VIEW_XML_MAP[VIEW_SHOWCASE]) + addon_path = addon.getAddonInfo('path') + window = BrowseWindow(xml_file, addon_path, 'Default', '1080i') + window.set_movies(movies) + window.set_addon_id(addon_id) + window.set_preview_mode(theme_index) + window.doModal() + + result = window.result + if result is None: + dialog.notification(_notify_title, "Browse: closed") + elif isinstance(result, dict): + title = result.get("title", result.get("movie", {}).get("title", "")) + dialog.notification(_notify_title, "Browse: %s" % title) + else: + dialog.notification(_notify_title, "Browse: %s" % result) + + +def preview_context_menu() -> None: + """Show the ContextMenuWindow with a movie that has a set.""" + movies = _fetch_preview_movies() + movie = _find_set_movie(movies) + + from resources.lib.ui.context_menu import show_context_menu + result = show_context_menu(movie, addon_id=addon_id) + dialog.notification(_notify_title, "Context: %s" % result) + + +def preview_continuation() -> None: + """Show the ContinuationDialog with countdown (playlist continuation).""" + from resources.lib.playback.playback_monitor import ContinuationDialog + from resources.lib.utils import lang + + pair = _fetch_set_pair() + finished_title = pair["finished"].get("title", "Preview Movie") + next_movie = pair["next_movie"] + next_title = next_movie.get("title", "Next Movie") + set_name = pair["set_name"] + art = next_movie.get("art", {}) + poster = art.get("poster", "") if isinstance(art, dict) else "" + + cd = ContinuationDialog( + 'script-easymovie-continuation.xml', + script_path, 'Default', '1080i', + message=f"{lang(32333)}[CR][B]{finished_title}[/B]", + subtitle=f"{lang(32332)} [B]{set_name}[/B]:[CR]{next_title}", + yes_label=lang(32330), + no_label=lang(32331), + poster=poster, + duration=15, + default_yes=True, + heading=addon_name, + addon_id=addon_id, + ) + cd.doModal() + dialog.notification( + _notify_title, + "Continuation: result=%s" % cd.result + ) + del cd + + +def preview_set_warning() -> None: + """Show the ContinuationDialog as set warning (no countdown).""" + from resources.lib.playback.playback_monitor import ContinuationDialog + from resources.lib.utils import lang + + pair = _fetch_set_pair() + movie = pair["finished"] + title = movie.get("title", "Preview Movie") + year = str(movie.get("year", 2024)) + set_name = pair["set_name"] + art = movie.get("art", {}) + poster = art.get("poster", "") if isinstance(art, dict) else "" + + cd = ContinuationDialog( + 'script-easymovie-setwarning.xml', + script_path, 'Default', '1080i', + message=( + f"[B]{title}[/B] ({year})[CR]" + f"{lang(32340)} [B]{set_name}[/B][CR]" + f"{lang(32341)}" + ), + subtitle=lang(32342), + yes_label=lang(32300), + no_label=lang(32301), + poster=poster, + duration=0, + default_yes=True, + heading=addon_name, + addon_id=addon_id, + ) + cd.doModal() + dialog.notification( + _notify_title, + "Set warning: result=%s" % cd.result + ) + del cd + + +def Main(override_addon_id: Optional[str] = None) -> None: + """Show the dialog preview selection menu. + + Args: + override_addon_id: If set, use this addon ID for theming and + name display instead of the running addon's own ID. + Useful for testing clones. + """ + global addon_id, addon_name, script_path, _notify_title + + addon = xbmcaddon.Addon(override_addon_id) if override_addon_id else xbmcaddon.Addon() + addon_id = addon.getAddonInfo('id') + addon_name = addon.getAddonInfo('name') + script_path = addon.getAddonInfo('path') + _notify_title = "%s Preview" % addon_name + + # Theme picker — temporarily override for the preview session + theme_names = ["Golden Hour", "Ultraviolet", "Ember", "Nightfall"] + original_theme = addon.getSetting('theme') or '0' + current_name = theme_names[int(original_theme)] if original_theme.isdigit() and int(original_theme) < len(theme_names) else theme_names[0] + + theme_options = ["Keep current (%s)" % current_name] + theme_names + theme_choice = dialog.select("Theme Color", theme_options) # type: ignore[arg-type] + if theme_choice < 0: + return + if theme_choice > 0: + addon.setSetting('theme', str(theme_choice - 1)) + + try: + options = [ + "1. Confirm Dialog", + "2. Confirm Dialog (OK only)", + "3. Select Dialog (single)", + "4. Select Dialog (multi)", + "5. Browse Window", + "6. Context Menu", + "7. Continuation Dialog (countdown)", + "8. Set Warning Dialog (no countdown)", + "9. All Dialogs (cycle through)", + ] + + menu_title = "%s Dialog Preview [%s]" % (addon_name, addon_id) + choice = dialog.select(menu_title, options) # type: ignore[arg-type] + + previews = [ + preview_confirm, + preview_confirm_single, + preview_select_single, + preview_select_multi, + preview_browse, + preview_context_menu, + preview_continuation, + preview_set_warning, + ] + + if 0 <= choice < len(previews): + previews[choice]() + elif choice == len(previews): + for fn in previews: + fn() + finally: + # Restore original theme + if theme_choice > 0: + addon.setSetting('theme', original_theme) diff --git a/script.easymovie/resources/icons/icon-ember.png b/script.easymovie/resources/icons/icon-ember.png new file mode 100644 index 000000000..d788e7c66 Binary files /dev/null and b/script.easymovie/resources/icons/icon-ember.png differ diff --git a/script.easymovie/resources/icons/icon-golden-hour.png b/script.easymovie/resources/icons/icon-golden-hour.png new file mode 100644 index 000000000..1c72a8fe6 Binary files /dev/null and b/script.easymovie/resources/icons/icon-golden-hour.png differ diff --git a/script.easymovie/resources/icons/icon-nightfall.png b/script.easymovie/resources/icons/icon-nightfall.png new file mode 100644 index 000000000..58c6b0533 Binary files /dev/null and b/script.easymovie/resources/icons/icon-nightfall.png differ diff --git a/script.easymovie/resources/icons/icon-ultraviolet.png b/script.easymovie/resources/icons/icon-ultraviolet.png new file mode 100644 index 000000000..10f40ac87 Binary files /dev/null and b/script.easymovie/resources/icons/icon-ultraviolet.png differ diff --git a/script.easymovie/resources/language/resource.language.en_gb/strings.po b/script.easymovie/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 000000000..062272110 --- /dev/null +++ b/script.easymovie/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,987 @@ +# Kodi Media Center language file +# Addon Name: EasyMovie +# Addon id: script.easymovie +# Addon Provider: Rouzax +msgid "" +msgstr "" +"Project-Id-Version: Kodi Addons\n" +"Report-Msgid-Bugs-To: https://github.com/Rouzax/script.easymovie/issues\n" +"POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Kodi Translation Team\n" +"Language-Team: English\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +# ============================================================================= +# STRING ID RANGES +# ============================================================================= +# +# 32000-32099: Settings categories and group labels +# 32100-32199: Setting labels +# 32200-32299: Filter/wizard labels and options +# 32300-32399: UI labels (dialogs, buttons, browse views) +# 32400-32499: (reserved for future use) +# 32500-32599: Settings option values (spinners) +# 32600-32699: Help text for settings +# 32700-32799: Clone and update messages +# +# ============================================================================= + + +# ============================================================================= +# SETTINGS CATEGORIES & GROUPS (32000-32099) +# ============================================================================= + +msgctxt "#32000" +msgid "EasyMovie" +msgstr "" + +msgctxt "#32001" +msgid "Filters" +msgstr "" + +msgctxt "#32002" +msgid "Movie Sets" +msgstr "" + +msgctxt "#32003" +msgid "Browse Mode" +msgstr "" + +msgctxt "#32004" +msgid "Playlist Mode" +msgstr "" + +msgctxt "#32005" +msgid "Playback" +msgstr "" + +msgctxt "#32006" +msgid "Advanced" +msgstr "" + +# Group labels +msgctxt "#32010" +msgid "On Launch" +msgstr "" + +msgctxt "#32011" +msgid "Appearance" +msgstr "" + +msgctxt "#32012" +msgid "Genre" +msgstr "" + +msgctxt "#32013" +msgid "Watched" +msgstr "" + +msgctxt "#32014" +msgid "Age Rating" +msgstr "" + +msgctxt "#32015" +msgid "Runtime" +msgstr "" + +msgctxt "#32016" +msgid "Year" +msgstr "" + +msgctxt "#32017" +msgid "Score" +msgstr "" + +msgctxt "#32018" +msgid "Set Awareness" +msgstr "" + +msgctxt "#32019" +msgid "Continuation" +msgstr "" + +msgctxt "#32020" +msgid "Results" +msgstr "" + +msgctxt "#32021" +msgid "Basics" +msgstr "" + +msgctxt "#32022" +msgid "On Launch" +msgstr "" + +msgctxt "#32023" +msgid "During Playback" +msgstr "" + +msgctxt "#32024" +msgid "Notifications" +msgstr "" + +msgctxt "#32025" +msgid "Movie Pool" +msgstr "" + +msgctxt "#32026" +msgid "Re-suggestion" +msgstr "" + +msgctxt "#32027" +msgid "Debugging" +msgstr "" + +msgctxt "#32028" +msgid "Tools" +msgstr "" + +msgctxt "#32029" +msgid "Warnings" +msgstr "" + + +# ============================================================================= +# SETTING LABELS (32100-32199) +# ============================================================================= + +# Category 1: EasyMovie +msgctxt "#32100" +msgid "When I open EasyMovie" +msgstr "" + +msgctxt "#32101" +msgid "Theme" +msgstr "" + +msgctxt "#32102" +msgid "Set custom icon" +msgstr "" + +msgctxt "#32103" +msgid "Reset to default icon" +msgstr "" + +# Category 2: Filters +msgctxt "#32110" +msgid "Genre filter" +msgstr "" + +msgctxt "#32111" +msgid "Genre matching" +msgstr "" + +msgctxt "#32112" +msgid "Select genres..." +msgstr "" + +msgctxt "#32113" +msgid "Selected genres" +msgstr "" + +msgctxt "#32114" +msgid "Watched filter" +msgstr "" + +msgctxt "#32115" +msgid "Watched status" +msgstr "" + +msgctxt "#32116" +msgid "Age rating filter" +msgstr "" + +msgctxt "#32117" +msgid "Select ratings..." +msgstr "" + +msgctxt "#32118" +msgid "Selected ratings" +msgstr "" + +msgctxt "#32119" +msgid "Runtime filter" +msgstr "" + +msgctxt "#32120" +msgid "Minimum runtime (minutes)" +msgstr "" + +msgctxt "#32121" +msgid "Maximum runtime (minutes)" +msgstr "" + +msgctxt "#32122" +msgid "Year filter" +msgstr "" + +msgctxt "#32123" +msgid "Year filter type" +msgstr "" + +msgctxt "#32124" +msgid "From year" +msgstr "" + +msgctxt "#32125" +msgid "To year" +msgstr "" + +msgctxt "#32126" +msgid "Score filter" +msgstr "" + +msgctxt "#32127" +msgid "Minimum score" +msgstr "" + +# Category 3: Movie Sets +msgctxt "#32130" +msgid "Enable movie set awareness" +msgstr "" + +msgctxt "#32131" +msgid "Show set information" +msgstr "" + +msgctxt "#32132" +msgid "Enable continuation prompts" +msgstr "" + +msgctxt "#32133" +msgid "Countdown duration (seconds)" +msgstr "" + +msgctxt "#32134" +msgid "If countdown expires" +msgstr "" + +# Category 4: Browse Mode +msgctxt "#32140" +msgid "View style" +msgstr "" + +msgctxt "#32141" +msgid "Return to EasyMovie after playback" +msgstr "" + +msgctxt "#32142" +msgid "Number of movies" +msgstr "" + +msgctxt "#32143" +msgid "Sort by" +msgstr "" + +msgctxt "#32144" +msgid "Sort direction" +msgstr "" + +# Category 5: Playlist Mode +msgctxt "#32150" +msgid "Number of movies" +msgstr "" + +msgctxt "#32151" +msgid "Sort by" +msgstr "" + +msgctxt "#32152" +msgid "Sort direction" +msgstr "" + +msgctxt "#32153" +msgid "Start playlist with unfinished movies" +msgstr "" + +msgctxt "#32154" +msgid "Seek to resume point for movies" +msgstr "" + +# Category 6: Playback +msgctxt "#32160" +msgid "Check for in-progress movie on launch" +msgstr "" + +msgctxt "#32161" +msgid "Show info when playing" +msgstr "" + +msgctxt "#32162" +msgid "Show processing notifications" +msgstr "" + +msgctxt "#32163" +msgid "Warn about earlier unwatched movies in set" +msgstr "" + +# Category 7: Advanced +msgctxt "#32170" +msgid "Limit to smart playlist" +msgstr "" + +msgctxt "#32171" +msgid "Select playlist..." +msgstr "" + +msgctxt "#32172" +msgid "Selected playlist" +msgstr "" + +msgctxt "#32173" +msgid "Avoid re-suggesting recent movies" +msgstr "" + +msgctxt "#32174" +msgid "Re-suggestion window" +msgstr "" + +msgctxt "#32175" +msgid "Remember last wizard answers" +msgstr "" + +msgctxt "#32176" +msgid "Enable debug logging" +msgstr "" + +msgctxt "#32177" +msgid "Create EasyMovie copy..." +msgstr "" + +msgctxt "#32178" +msgid "Show movie counts in wizard" +msgstr "" + +msgctxt "#32179" +msgid "Cumulative counts" +msgstr "" + +msgctxt "#32180" +msgid "Ignore genre filter" +msgstr "" + +msgctxt "#32181" +msgid "Ignore genre matching" +msgstr "" + +msgctxt "#32182" +msgid "Select genres to ignore..." +msgstr "" + +msgctxt "#32183" +msgid "Ignored genres" +msgstr "" + + +# ============================================================================= +# FILTER / WIZARD LABELS (32200-32299) +# ============================================================================= + +# Wizard dialog headings +msgctxt "#32200" +msgid "Select Genres" +msgstr "" + +msgctxt "#32201" +msgid "Select Age Ratings" +msgstr "" + +msgctxt "#32202" +msgid "Select Time Period" +msgstr "" + +msgctxt "#32203" +msgid "Select Playlist" +msgstr "" + +msgctxt "#32204" +msgid "Ignore Genres" +msgstr "" + +# Wizard option: year filter group header +msgctxt "#32206" +msgid "— By decade —" +msgstr "" + +# Wizard option: year recency +msgctxt "#32210" +msgid "Last year" +msgstr "" + +msgctxt "#32211" +msgid "Last 2 years" +msgstr "" + +msgctxt "#32212" +msgid "Last 5 years" +msgstr "" + +msgctxt "#32213" +msgid "Last 10 years" +msgstr "" + +msgctxt "#32214" +msgid "Last 20 years" +msgstr "" + +# Wizard option: year catch-all +msgctxt "#32220" +msgid "Any year" +msgstr "" + + +# ============================================================================= +# UI LABELS (32300-32399) +# ============================================================================= + +# Common dialog buttons +msgctxt "#32300" +msgid "OK" +msgstr "" + +msgctxt "#32301" +msgid "Cancel" +msgstr "" + +msgctxt "#32302" +msgid "Back" +msgstr "" + +# Browse view buttons +msgctxt "#32310" +msgid "Surprise Me" +msgstr "" + +msgctxt "#32311" +msgid "Re-roll" +msgstr "" + +msgctxt "#32312" +msgid "Play" +msgstr "" + +msgctxt "#32313" +msgid "Play Full Set" +msgstr "" + +# Mode selection +msgctxt "#32320" +msgid "Choose Mode" +msgstr "" + +msgctxt "#32321" +msgid "Browse" +msgstr "" + +msgctxt "#32322" +msgid "Playlist" +msgstr "" + +# Continuation prompts +msgctxt "#32330" +msgid "Watch Next" +msgstr "" + +msgctxt "#32331" +msgid "Skip" +msgstr "" + +msgctxt "#32332" +msgid "Next in" +msgstr "" + +msgctxt "#32333" +msgid "You just watched" +msgstr "" + +# Set warning dialog +msgctxt "#32340" +msgid "from" +msgstr "" + +msgctxt "#32341" +msgid "is in your library and unwatched." +msgstr "" + +msgctxt "#32342" +msgid "Would you like to watch it instead?" +msgstr "" + +# Notification toasts +msgctxt "#32350" +msgid "Finding movies..." +msgstr "" + + +# ============================================================================= +# SETTINGS OPTION VALUES / SPINNERS (32500-32599) +# ============================================================================= + +# Primary function +msgctxt "#32500" +msgid "Browse" +msgstr "" + +msgctxt "#32501" +msgid "Playlist" +msgstr "" + +msgctxt "#32502" +msgid "Ask each time" +msgstr "" + +# Filter modes +msgctxt "#32503" +msgid "Ask" +msgstr "" + +msgctxt "#32504" +msgid "Pre-set" +msgstr "" + +msgctxt "#32505" +msgid "Skip" +msgstr "" + +# Genre matching +msgctxt "#32506" +msgid "Any selected genre (OR)" +msgstr "" + +msgctxt "#32507" +msgid "All selected genres (AND)" +msgstr "" + +# Watched +msgctxt "#32508" +msgid "Unwatched" +msgstr "" + +msgctxt "#32509" +msgid "Watched" +msgstr "" + +msgctxt "#32510" +msgid "Both" +msgstr "" + +# Year filter type +msgctxt "#32511" +msgid "After year" +msgstr "" + +msgctxt "#32512" +msgid "Before year" +msgstr "" + +msgctxt "#32513" +msgid "Between years" +msgstr "" + +# Sort options +msgctxt "#32514" +msgid "Random" +msgstr "" + +msgctxt "#32515" +msgid "Title" +msgstr "" + +msgctxt "#32516" +msgid "Year" +msgstr "" + +msgctxt "#32517" +msgid "Rating" +msgstr "" + +msgctxt "#32518" +msgid "Runtime" +msgstr "" + +msgctxt "#32519" +msgid "Date Added" +msgstr "" + +# Sort direction +msgctxt "#32520" +msgid "Ascending" +msgstr "" + +msgctxt "#32521" +msgid "Descending" +msgstr "" + +# View styles +msgctxt "#32522" +msgid "Showcase" +msgstr "" + +msgctxt "#32523" +msgid "Card List" +msgstr "" + +msgctxt "#32524" +msgid "Posters" +msgstr "" + +msgctxt "#32525" +msgid "Big Screen" +msgstr "" + +msgctxt "#32526" +msgid "Split View" +msgstr "" + +# Themes +msgctxt "#32527" +msgid "Golden Hour" +msgstr "" + +msgctxt "#32528" +msgid "Ultraviolet" +msgstr "" + +msgctxt "#32529" +msgid "Ember" +msgstr "" + +msgctxt "#32530" +msgid "Nightfall" +msgstr "" + +# Re-suggestion window +msgctxt "#32531" +msgid "4 hours" +msgstr "" + +msgctxt "#32532" +msgid "8 hours" +msgstr "" + +msgctxt "#32533" +msgid "12 hours" +msgstr "" + +msgctxt "#32534" +msgid "24 hours" +msgstr "" + +msgctxt "#32535" +msgid "48 hours" +msgstr "" + +msgctxt "#32536" +msgid "72 hours" +msgstr "" + +# Continuation default action +msgctxt "#32537" +msgid "Continue set" +msgstr "" + +msgctxt "#32538" +msgid "Continue playlist" +msgstr "" + +msgctxt "#32539" +msgid "Less than X years ago" +msgstr "" + +msgctxt "#32540" +msgid "Maximum age (years)" +msgstr "" + + +# ============================================================================= +# HELP TEXT (32600-32699) +# ============================================================================= + +msgctxt "#32600" +msgid "Choose what happens when you launch EasyMovie" +msgstr "" + +msgctxt "#32601" +msgid "Choose the accent color theme for EasyMovie windows and dialogs" +msgstr "" + +msgctxt "#32602" +msgid "Ask: Show a dialog each time. Pre-set: Use the values below. Skip: Ignore this filter." +msgstr "" + +msgctxt "#32603" +msgid "OR: Movies matching any selected genre. AND: Movies matching all selected genres." +msgstr "" + +msgctxt "#32604" +msgid "Filter movies by watched status" +msgstr "" + +msgctxt "#32605" +msgid "Filter movies by age rating (MPAA/certification)" +msgstr "" + +msgctxt "#32606" +msgid "Filter movies by runtime in minutes. Set to 0 for no limit." +msgstr "" + +msgctxt "#32607" +msgid "Filter movies by release year" +msgstr "" + +msgctxt "#32608" +msgid "Filter movies by minimum score (0-100, where 70 = 7.0 rating)" +msgstr "" + +msgctxt "#32609" +msgid "When enabled, EasyMovie suggests the first unwatched movie in a collection instead of a random one" +msgstr "" + +msgctxt "#32610" +msgid "Show a prompt when a collection movie finishes to watch the next one" +msgstr "" + +msgctxt "#32611" +msgid "Seconds before the continuation prompt auto-dismisses" +msgstr "" + +msgctxt "#32612" +msgid "What happens when the countdown timer reaches zero without user input" +msgstr "" + +msgctxt "#32613" +msgid "Visual layout for the browse results screen" +msgstr "" + +msgctxt "#32614" +msgid "Return to the movie list after a movie finishes playing" +msgstr "" + +msgctxt "#32615" +msgid "How many movies to show in the results" +msgstr "" + +msgctxt "#32616" +msgid "How to order the results" +msgstr "" + +msgctxt "#32617" +msgid "Partially watched movies play first in the playlist" +msgstr "" + +msgctxt "#32618" +msgid "Automatically skip to where you left off for movies" +msgstr "" + +msgctxt "#32619" +msgid "Check if you have a partially watched movie when EasyMovie launches" +msgstr "" + +msgctxt "#32620" +msgid "Limit the movie pool to a Kodi smart playlist" +msgstr "" + +msgctxt "#32621" +msgid "Avoid suggesting the same movies again within the selected time window" +msgstr "" + +msgctxt "#32622" +msgid "How long to wait before a movie can be suggested again" +msgstr "" + +msgctxt "#32623" +msgid "Pre-fill wizard dialogs with your last answers" +msgstr "" + +msgctxt "#32624" +msgid "Write detailed diagnostic information to a separate log file" +msgstr "" + +msgctxt "#32625" +msgid "Create a separate copy of EasyMovie with its own settings" +msgstr "" + +msgctxt "#32626" +msgid "Display how many movies match each option in the filter wizard" +msgstr "" + +msgctxt "#32627" +msgid "Count against remaining movies after each filter step (slower on low-end hardware)" +msgstr "" + +msgctxt "#32628" +msgid "Choose from built-in icon themes or browse for a custom image. The addon icon shown in Kodi menus and notifications will update immediately." +msgstr "" + +msgctxt "#32629" +msgid "Restore the original EasyMovie icon." +msgstr "" + +msgctxt "#32630" +msgid "Filter by unwatched, watched, or both" +msgstr "" + +msgctxt "#32631" +msgid "Only include movies at least this long. Set to 0 for no minimum." +msgstr "" + +msgctxt "#32632" +msgid "Only include movies no longer than this. Set to 0 for no maximum." +msgstr "" + +msgctxt "#32633" +msgid "Choose how to filter by year: after, before, between, or recent" +msgstr "" + +msgctxt "#32634" +msgid "The starting year for the filter range" +msgstr "" + +msgctxt "#32635" +msgid "The ending year for the filter range" +msgstr "" + +msgctxt "#32636" +msgid "Only include movies released within this many years" +msgstr "" + +msgctxt "#32637" +msgid "Only include movies rated at or above this score (0–100)" +msgstr "" + +msgctxt "#32638" +msgid "Display movie set name and position in the browse view" +msgstr "" + +msgctxt "#32639" +msgid "Choose ascending or descending sort order" +msgstr "" + +msgctxt "#32640" +msgid "Show a notification with title and details when a movie starts playing" +msgstr "" + +msgctxt "#32641" +msgid "Show progress notifications while EasyMovie searches and filters your library" +msgstr "" + +msgctxt "#32642" +msgid "If you start a movie but there's an earlier unwatched one in the same set, ask if you want to watch that instead." +msgstr "" + +msgctxt "#32643" +msgid "Ask: Show a dialog each time. Pre-set: Use the values below. Skip: Ignore this filter." +msgstr "" + +msgctxt "#32644" +msgid "OR: Exclude movies matching any ignored genre. AND: Only exclude movies matching all ignored genres." +msgstr "" + +msgctxt "#32645" +msgid "Open selector to choose genres that will be excluded from results." +msgstr "" + +msgctxt "#32646" +msgid "Genres currently excluded from results." +msgstr "" + +msgctxt "#32647" +msgid "Open selector to choose which genres to include in results." +msgstr "" + +msgctxt "#32648" +msgid "Genres currently included in results." +msgstr "" + +msgctxt "#32649" +msgid "Open selector to choose which age ratings to include in results." +msgstr "" + +msgctxt "#32650" +msgid "Age ratings currently included in results." +msgstr "" + +msgctxt "#32651" +msgid "Select a Kodi smart playlist (.xsp) file to use as the movie pool." +msgstr "" + +msgctxt "#32652" +msgid "Smart playlist currently used as the movie pool." +msgstr "" + +# ============================================================================= +# CLONE & UPDATE (32700-32799) +# ============================================================================= + +msgctxt "#32700" +msgid "This creates a copy of EasyMovie with its own settings." +msgstr "" + +msgctxt "#32701" +msgid "It creates a new folder in your addons directory." +msgstr "" + +msgctxt "#32702" +msgid "Do you wish to continue?" +msgstr "" + +msgctxt "#32703" +msgid "EasyMovie - " +msgstr "" + +msgctxt "#32704" +msgid "That name is already in use. Please try another." +msgstr "" + +msgctxt "#32705" +msgid "Clone created successfully." +msgstr "" + +msgctxt "#32706" +msgid "Restart Kodi for settings labels to appear." +msgstr "" + +msgctxt "#32707" +msgid "An error occurred while creating the clone." +msgstr "" + +msgctxt "#32708" +msgid "Operation cancelled." +msgstr "" + +msgctxt "#32709" +msgid "A new version of EasyMovie is available." +msgstr "" + +msgctxt "#32710" +msgid "The clone needs to be updated before it can be used." +msgstr "" + +msgctxt "#32711" +msgid "Update now?" +msgstr "" + +msgctxt "#32712" +msgid "Update" +msgstr "" + +msgctxt "#32713" +msgid "Update successful." +msgstr "" + +msgctxt "#32714" +msgid "Restart Kodi for all changes to take effect." +msgstr "" + +msgctxt "#32715" +msgid "An error occurred while updating the clone." +msgstr "" + +msgctxt "#32716" +msgid "Continue" +msgstr "" diff --git a/script.easymovie/resources/lib/__init__.py b/script.easymovie/resources/lib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/script.easymovie/resources/lib/constants.py b/script.easymovie/resources/lib/constants.py new file mode 100644 index 000000000..3364285a3 --- /dev/null +++ b/script.easymovie/resources/lib/constants.py @@ -0,0 +1,161 @@ +""" +Centralized constants for EasyMovie. + +All magic numbers, string literals, property names, setting IDs, +and configuration values live here. Import from this module rather +than hardcoding values elsewhere. +""" + +# Addon identity +ADDON_ID = "script.easymovie" +ADDON_NAME = "EasyMovie" + +# Icon persistence +CUSTOM_ICON_BACKUP = "custom_icon.png" + +# Log file configuration +LOG_DIR = "logs" +LOG_FILENAME = "easymovie.log" +LOG_MAX_BYTES = 500 * 1024 # 500KB +LOG_BACKUP_COUNT = 3 +LOG_MAX_VALUE_LENGTH = 200 +LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S.%f" +LOG_TIMESTAMP_TRIM = 23 # Trim microseconds to milliseconds + +# Primary function modes +MODE_BROWSE = 0 +MODE_PLAYLIST = 1 +MODE_ASK = 2 + +# Filter modes (for settings: Ask / Pre-set / Skip) +FILTER_ASK = 0 +FILTER_PRESET = 1 +FILTER_SKIP = 2 + +# Watched status values +WATCHED_UNWATCHED = 0 +WATCHED_WATCHED = 1 +WATCHED_BOTH = 2 + +# Year filter types +YEAR_FILTER_AFTER = 0 +YEAR_FILTER_RECENCY = 3 + +# Sort options +SORT_RANDOM = 0 +SORT_TITLE = 1 +SORT_YEAR = 2 +SORT_RATING = 3 +SORT_RUNTIME = 4 +SORT_DATE_ADDED = 5 + +# Sort directions +SORT_ASC = 0 +SORT_DESC = 1 + +# View styles +VIEW_SHOWCASE = 0 # Horizontal filmstrip carousel +VIEW_CARD_LIST = 1 +VIEW_POSTERS = 2 +VIEW_BIG_SCREEN = 3 +VIEW_SPLIT_VIEW = 4 + +# Theme IDs (shared with EasyTV) +THEME_GOLDEN_HOUR = 0 +THEME_ULTRAVIOLET = 1 +THEME_EMBER = 2 +THEME_NIGHTFALL = 3 + +# Theme color definitions (AARRGGBB format) +# Same values as EasyTV for visual consistency +THEME_COLORS = { + THEME_GOLDEN_HOUR: { + 'EasyMovie.Accent': 'FFF5A623', + 'EasyMovie.AccentGlow': 'FFF5C564', + 'EasyMovie.AccentBG': '59B4781E', + 'EasyMovie.ButtonTextFocused': 'FF0D1117', + 'EasyMovie.ButtonFocus': 'FFD4912A', + }, + THEME_ULTRAVIOLET: { + 'EasyMovie.Accent': 'FFA78BFA', + 'EasyMovie.AccentGlow': 'FFC4B5FD', + 'EasyMovie.AccentBG': '596432B4', + 'EasyMovie.ButtonTextFocused': 'FFFFFFFF', + 'EasyMovie.ButtonFocus': 'FF7C3AED', + }, + THEME_EMBER: { + 'EasyMovie.Accent': 'FFF87171', + 'EasyMovie.AccentGlow': 'FFFCA5A5', + 'EasyMovie.AccentBG': '59B43232', + 'EasyMovie.ButtonTextFocused': 'FFFFFFFF', + 'EasyMovie.ButtonFocus': 'FFEF4444', + }, + THEME_NIGHTFALL: { + 'EasyMovie.Accent': 'FF60A5FA', + 'EasyMovie.AccentGlow': 'FF93C5FD', + 'EasyMovie.AccentBG': '59286AB4', + 'EasyMovie.ButtonTextFocused': 'FFFFFFFF', + 'EasyMovie.ButtonFocus': 'FF3B82F6', + }, +} + +# Re-suggestion window options (hours) +RESURFACE_WINDOWS = { + 0: 4, + 1: 8, + 2: 12, + 3: 24, + 4: 48, + 5: 72, +} + +# Runtime filter ranges for wizard (in minutes) +RUNTIME_RANGES = [ + (0, 90, "Under 90 minutes"), + (90, 120, "90 – 120 minutes"), + (120, 150, "120 – 150 minutes"), + (150, 0, "Over 150 minutes"), # 0 = no upper limit + (0, 0, "Any runtime"), # both 0 = no filter +] + +# Recency ranges for year filter wizard (years_ago, lang_id) +RECENCY_RANGES = [ + (1, 32210), # "Last year" + (2, 32211), # "Last 2 years" + (5, 32212), # "Last 5 years" + (10, 32213), # "Last 10 years" + (20, 32214), # "Last 20 years" +] + +# Score filter ranges for wizard +SCORE_RANGES = [ + (80, "8.0+ (Excellent)"), + (70, "7.0+ (Good)"), + (60, "6.0+ (Above Average)"), + (50, "5.0+ (Average)"), + (0, "Any score"), +] + +# Timing constants (milliseconds) +NOTIFICATION_DURATION_MS = 5000 +PLAYLIST_ADD_DELAY_MS = 50 + +# Continuation prompt +CONTINUATION_DEFAULT_CONTINUE_SET = 0 + +# Window properties for service coordination +PROP_PLAYLIST_RUNNING = "EasyMovie.PlaylistRunning" + +# Playback monitor timing (milliseconds) +PLAYER_STOP_DELAY_MS = 500 + +# Kodi GUI action IDs +ACTION_MOVE_UP = 3 +ACTION_MOVE_DOWN = 4 +ACTION_PREVIOUS_MENU = 10 +ACTION_NAV_BACK = 92 +ACTION_CONTEXT_MENU = 117 +ACTION_TELETEXT_BLUE = 218 + +# Theme names (for UI display, e.g. preview mode cycling) +THEME_NAMES = ["Golden Hour", "Ultraviolet", "Ember", "Nightfall"] diff --git a/script.easymovie/resources/lib/data/__init__.py b/script.easymovie/resources/lib/data/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/script.easymovie/resources/lib/data/filters.py b/script.easymovie/resources/lib/data/filters.py new file mode 100644 index 000000000..c07902ed8 --- /dev/null +++ b/script.easymovie/resources/lib/data/filters.py @@ -0,0 +1,199 @@ +""" +Movie filter engine. + +Applies user-configured filters to a list of movie dicts from +Kodi's JSON-RPC API. All filtering is client-side after an +initial bulk query. + +Logging: + Logger: 'data' + Key events: + - filter.step (DEBUG): Per-step remaining count after each filter + - filter.apply (DEBUG): Filters applied with result count + See LOGGING.md for full guidelines. +""" +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Set + +from resources.lib.constants import WATCHED_BOTH, WATCHED_UNWATCHED, WATCHED_WATCHED +from resources.lib.utils import get_logger + +log = get_logger('data') + + +@dataclass +class FilterConfig: + """Configuration for movie filtering.""" + ignore_genres: Optional[List[str]] = None + ignore_genre_match_and: bool = False # False = OR, True = AND + genres: Optional[List[str]] = None + genre_match_and: bool = False # False = OR, True = AND + watched: int = WATCHED_BOTH # 0=unwatched, 1=watched, 2=both + mpaa_ratings: Optional[List[str]] = None + runtime_min: int = 0 # minutes, 0 = no minimum + runtime_max: int = 0 # minutes, 0 = no maximum + year_from: int = 0 # 0 = no lower bound + year_to: int = 0 # 0 = no upper bound + min_score: int = 0 # 0-100 (divide by 10 for comparison) + exclude_ids: Optional[List[int]] = field(default_factory=list) + + +def apply_filters( + movies: List[Dict[str, Any]], config: FilterConfig, + reason: str = "final", +) -> List[Dict[str, Any]]: + """Apply all configured filters to a list of movies. + + Args: + movies: List of movie dicts from Kodi JSON-RPC. + config: Filter configuration. + reason: Why filters are being applied. "final" logs per-step detail, + "cumulative_count" logs only the summary. + + Returns: + Filtered list of movie dicts. + """ + result = movies + verbose = reason == "final" + + # Exclude specific movie IDs (previously suggested, blacklisted) + if config.exclude_ids: + exclude_set = set(config.exclude_ids) + result = [m for m in result if m.get("movieid", 0) not in exclude_set] + if verbose: + log.debug("Filter step", event="filter.step", + step="exclude_ids", remaining=len(result)) + + # Ignore genres filter (exclude matching movies) + if config.ignore_genres: + ignore_set = set(config.ignore_genres) + if config.ignore_genre_match_and: + # AND: only exclude if ALL ignored genres present + result = [m for m in result + if not ignore_set.issubset(set(m.get("genre", [])))] + else: + # OR: exclude if ANY ignored genre present + result = [m for m in result + if not ignore_set.intersection(set(m.get("genre", [])))] + if verbose: + log.debug("Filter step", event="filter.step", + step="ignore_genres", remaining=len(result)) + + # Genre filter + if config.genres: + genre_set = set(config.genres) + if config.genre_match_and: + result = [m for m in result if genre_set.issubset(set(m.get("genre", [])))] + else: + result = [m for m in result if genre_set.intersection(set(m.get("genre", [])))] + if verbose: + log.debug("Filter step", event="filter.step", + step="genre", remaining=len(result)) + + # Watched status + if config.watched == WATCHED_UNWATCHED: + result = [m for m in result if m.get("playcount", 0) == 0] + if verbose: + log.debug("Filter step", event="filter.step", + step="watched", remaining=len(result)) + elif config.watched == WATCHED_WATCHED: + result = [m for m in result if m.get("playcount", 0) > 0] + if verbose: + log.debug("Filter step", event="filter.step", + step="watched", remaining=len(result)) + # WATCHED_BOTH: no filter + + # MPAA rating + if config.mpaa_ratings: + mpaa_set = set(config.mpaa_ratings) + result = [m for m in result if m.get("mpaa", "") in mpaa_set] + if verbose: + log.debug("Filter step", event="filter.step", + step="mpaa", remaining=len(result)) + + # Runtime (Kodi stores in seconds, config uses minutes) + if config.runtime_min > 0: + min_seconds = config.runtime_min * 60 + result = [m for m in result if m.get("runtime", 0) >= min_seconds] + if verbose: + log.debug("Filter step", event="filter.step", + step="runtime_min", remaining=len(result)) + if config.runtime_max > 0: + max_seconds = config.runtime_max * 60 + result = [m for m in result if m.get("runtime", 0) <= max_seconds] + if verbose: + log.debug("Filter step", event="filter.step", + step="runtime_max", remaining=len(result)) + + # Year + if config.year_from > 0: + result = [m for m in result if m.get("year", 0) >= config.year_from] + if verbose: + log.debug("Filter step", event="filter.step", + step="year_from", remaining=len(result)) + if config.year_to > 0: + result = [m for m in result if m.get("year", 0) <= config.year_to] + if verbose: + log.debug("Filter step", event="filter.step", + step="year_to", remaining=len(result)) + + # Score (config stores 0-100, Kodi rating is 0.0-10.0) + if config.min_score > 0: + min_rating = config.min_score / 10.0 + result = [m for m in result if m.get("rating", 0.0) >= min_rating] + if verbose: + log.debug("Filter step", event="filter.step", + step="score", remaining=len(result)) + + log.debug("Filters applied", event="filter.apply", + reason=reason, input_count=len(movies), result_count=len(result)) + return result + + +def filter_by_playlist_ids( + movies: List[Dict[str, Any]], playlist_ids: Set[int], +) -> List[Dict[str, Any]]: + """Filter movies to only those present in a smart playlist. + + Args: + movies: Full movie list from library query. + playlist_ids: Set of movie IDs from the smart playlist. + + Returns: + Movies whose movieid is in playlist_ids. + """ + return [m for m in movies if m.get("movieid", 0) in playlist_ids] + + +def extract_unique_genres(movies: List[Dict[str, Any]]) -> List[str]: + """Extract and sort all unique genres from a movie list.""" + genres = set() + for movie in movies: + for genre in movie.get("genre", []): + genres.add(genre) + return sorted(genres) + + +def extract_unique_mpaa(movies: List[Dict[str, Any]]) -> List[str]: + """Extract and sort all unique MPAA ratings from a movie list.""" + ratings = set() + for movie in movies: + mpaa = movie.get("mpaa", "") + if mpaa: + ratings.add(mpaa) + return sorted(ratings) + + +def extract_decade_buckets(movies: List[Dict[str, Any]]) -> List[tuple]: + """Extract decade buckets with counts from a movie list. + + Returns list of (decade_start, count, label) tuples, sorted descending. + Example: [(2020, 331, "2020s"), (1990, 3, "1990s")] + """ + from collections import Counter + decades = Counter((m.get("year", 0) // 10) * 10 for m in movies if m.get("year", 0) > 0) + buckets = [] + for decade, count in sorted(decades.items(), reverse=True): + label = f"{decade}s" + buckets.append((decade, count, label)) + return buckets diff --git a/script.easymovie/resources/lib/data/movie_sets.py b/script.easymovie/resources/lib/data/movie_sets.py new file mode 100644 index 000000000..1b736c3f6 --- /dev/null +++ b/script.easymovie/resources/lib/data/movie_sets.py @@ -0,0 +1,152 @@ +""" +Movie set awareness logic. + +Handles finding the correct movie within a set (first unwatched), +substituting random picks, and determining set continuations. + +Logging: + Logger: 'data' + Key events: + - results.set_substitute (DEBUG): Movie substituted for set-correct entry + - results.set_dedup (DEBUG): Duplicate set member skipped + - continuation.next_found (DEBUG): Next movie in set identified + See LOGGING.md for full guidelines. +""" +from typing import Dict, List, Optional, Any + +from resources.lib.utils import get_logger + +log = get_logger('data') + + +def find_first_unwatched_in_set( + set_details: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Find the first unwatched movie in a set (by release order). + + Args: + set_details: MovieSetDetails response with movies sorted by year. + + Returns: + First unwatched movie dict, or None if all watched. + """ + for movie in set_details.get("movies", []): + if movie.get("playcount", 0) == 0: + return movie + return None + + +def find_first_unwatched_before( + set_details: Dict[str, Any], + current_movie_id: int, +) -> Optional[Dict[str, Any]]: + """Find the first unwatched movie that comes before the given movie in a set. + + Used by the background service to detect when a user starts playing + a later movie in a set while an earlier one is still unwatched. + + Args: + set_details: MovieSetDetails response with movies sorted by year. + current_movie_id: The movie being played. + + Returns: + First unwatched movie before current, or None if none exist + or current movie is not in the set. + """ + movies = set_details.get("movies", []) + + # Verify the current movie is actually in this set + if not any(m.get("movieid") == current_movie_id for m in movies): + return None + + for movie in movies: + if movie.get("movieid") == current_movie_id: + # Reached the current movie — no earlier unwatched found + return None + if movie.get("playcount", 0) == 0: + return movie + return None + + +def apply_set_substitutions( + movies: List[Dict[str, Any]], + set_cache: Dict[int, Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Replace set-member movies with the first unwatched entry in their set. + + Also deduplicates: if multiple movies from the same set were picked, + only the first occurrence (substituted) is kept. + + Args: + movies: List of picked movie dicts. + set_cache: Dict of setid -> GetMovieSetDetails response. + + Returns: + New list with substitutions applied and set duplicates removed. + """ + seen_sets = set() + result = [] + + for movie in movies: + set_id = movie.get("setid", 0) + + if set_id and set_id in set_cache: + # Skip if we already have a movie from this set + if set_id in seen_sets: + log.debug("Duplicate set member skipped", + event="results.set_dedup", + title=movie.get("title", ""), + set_name=movie.get("set", "")) + continue + seen_sets.add(set_id) + + # Find first unwatched in set + first_unwatched = find_first_unwatched_in_set(set_cache[set_id]) + if first_unwatched is not None: + # Copy the substitute and preserve set info + substitute = dict(first_unwatched) + substitute["set"] = movie.get("set", "") + substitute["setid"] = set_id + log.debug("Movie substituted for set-correct entry", + event="results.set_substitute", + original_title=movie.get("title", ""), + substitute_title=substitute.get("title", ""), + set_name=movie.get("set", "")) + result.append(substitute) + else: + # All watched — keep original pick + result.append(movie) + else: + result.append(movie) + + return result + + +def get_next_in_set( + set_details: Dict[str, Any], + current_movie_id: int, +) -> Optional[Dict[str, Any]]: + """Get the next movie in a set after the given movie. + + Used for continuation prompts: "You just watched X, + want to watch Y next?" + + Args: + set_details: MovieSetDetails response with movies sorted by year. + current_movie_id: The movie that was just watched. + + Returns: + Next movie dict, or None if current is last or not found. + """ + movies = set_details.get("movies", []) + for i, movie in enumerate(movies): + if movie.get("movieid") == current_movie_id: + if i + 1 < len(movies): + next_movie = movies[i + 1] + log.debug("Next movie in set identified", + event="continuation.next_found", + current_title=movie.get("title", ""), + next_title=next_movie.get("title", "")) + return next_movie + return None + return None diff --git a/script.easymovie/resources/lib/data/queries.py b/script.easymovie/resources/lib/data/queries.py new file mode 100644 index 000000000..b7c709b7a --- /dev/null +++ b/script.easymovie/resources/lib/data/queries.py @@ -0,0 +1,189 @@ +""" +JSON-RPC query builders for movie operations. + +All Kodi JSON-RPC queries are constructed here. No other module +should build raw query dicts. + +""" +from typing import Dict, Any + + +def get_all_movies_query() -> Dict[str, Any]: + """Get all movies with properties needed for filtering (no art).""" + return { + "jsonrpc": "2.0", + "method": "VideoLibrary.GetMovies", + "params": { + "properties": [ + "title", "genre", "year", "rating", "runtime", + "mpaa", "set", "setid", "playcount", "dateadded", + "file", "resume", "lastplayed", + ], + "sort": {"method": "title"}, + }, + "id": 1, + } + + +def get_movie_details_with_art_query(movie_id: int) -> Dict[str, Any]: + """Get a single movie with art and plot for display.""" + return { + "jsonrpc": "2.0", + "method": "VideoLibrary.GetMovieDetails", + "params": { + "movieid": movie_id, + "properties": [ + "title", "genre", "year", "rating", "runtime", + "mpaa", "set", "setid", "playcount", "dateadded", + "plot", "art", "file", "resume", "lastplayed", + ], + }, + "id": 1, + } + + +def get_all_movie_sets_query() -> Dict[str, Any]: + """Get all movie sets.""" + return { + "jsonrpc": "2.0", + "method": "VideoLibrary.GetMovieSets", + "params": { + "properties": ["title", "playcount"], + "sort": {"method": "title"}, + }, + "id": 1, + } + + +def get_movie_set_details_query(set_id: int) -> Dict[str, Any]: + """Get movies within a set, sorted by year (release order).""" + return { + "jsonrpc": "2.0", + "method": "VideoLibrary.GetMovieSetDetails", + "params": { + "setid": set_id, + "properties": ["title"], + "movies": { + "properties": [ + "title", "playcount", "year", "runtime", + "rating", "genre", "art", "resume", + ], + "sort": {"method": "year"}, + }, + }, + "id": 1, + } + + +def get_playlist_files_query() -> Dict[str, Any]: + """Get list of video playlist files from Kodi's playlist directory.""" + return { + "jsonrpc": "2.0", + "method": "Files.GetDirectory", + "params": { + "directory": "special://profile/playlists/video/", + "media": "video", + }, + "id": 1, + } + + +def build_playlist_get_movies_query(playlist_path: str) -> Dict[str, Any]: + """Get movie contents of a smart playlist file. + + Uses Files.GetDirectory which evaluates the .xsp rules and returns + matching items, just like Kodi's UI would. + + Args: + playlist_path: Full path to the .xsp file (special:// or filesystem). + """ + return { + "jsonrpc": "2.0", + "method": "Files.GetDirectory", + "params": { + "directory": playlist_path, + "media": "video", + }, + "id": 1, + } + + +def get_clear_video_playlist_query() -> Dict[str, Any]: + """Clear the video playlist.""" + return { + "jsonrpc": "2.0", + "method": "Playlist.Clear", + "params": {"playlistid": 1}, + "id": 1, + } + + +def build_add_movie_query(movie_id: int, position: int = -1) -> Dict[str, Any]: + """Add a movie to the video playlist.""" + params: Dict[str, Any] = { + "playlistid": 1, + "item": {"movieid": movie_id}, + } + if position >= 0: + params["position"] = position + return { + "jsonrpc": "2.0", + "method": "Playlist.Add", + "params": params, + "id": 1, + } + + +def build_play_playlist_query(position: int = 0) -> Dict[str, Any]: + """Start playing the video playlist.""" + return { + "jsonrpc": "2.0", + "method": "Player.Open", + "params": {"item": {"playlistid": 1, "position": position}}, + "id": 1, + } + + +def build_play_movie_query(movie_id: int) -> Dict[str, Any]: + """Play a single movie directly.""" + return { + "jsonrpc": "2.0", + "method": "Player.Open", + "params": {"item": {"movieid": movie_id}}, + "id": 1, + } + + +def get_playing_item_query() -> Dict[str, Any]: + """Get information about the currently playing video item.""" + return { + "jsonrpc": "2.0", + "method": "Player.GetItem", + "params": { + "playerid": 1, + "properties": ["title", "setid", "set", "playcount", "type"], + }, + "id": 1, + } + + +def get_in_progress_movies_query() -> Dict[str, Any]: + """Get movies with a resume point (partially watched).""" + return { + "jsonrpc": "2.0", + "method": "VideoLibrary.GetMovies", + "params": { + "properties": [ + "title", "runtime", "resume", "lastplayed", + "art", "set", "setid", + ], + "filter": { + "field": "inprogress", + "operator": "true", + "value": "", + }, + "sort": {"method": "lastplayed", "order": "descending"}, + "limits": {"start": 0, "end": 10}, + }, + "id": 1, + } diff --git a/script.easymovie/resources/lib/data/results.py b/script.easymovie/resources/lib/data/results.py new file mode 100644 index 000000000..f75b02a31 --- /dev/null +++ b/script.easymovie/resources/lib/data/results.py @@ -0,0 +1,73 @@ +""" +Result selection and sorting. + +Handles selecting N random movies from a filtered pool and +sorting them according to user preferences. + +Logging: + Logger: 'data' + Key events: + - results.generate (DEBUG): Result set generated with count and sort + See LOGGING.md for full guidelines. +""" +import random +from typing import Dict, List, Any + +from resources.lib.constants import ( + SORT_RANDOM, SORT_TITLE, SORT_YEAR, SORT_RATING, + SORT_RUNTIME, SORT_DATE_ADDED, SORT_DESC, +) +from resources.lib.utils import get_logger + +log = get_logger('data') + + +# Sort key functions mapped to sort option constants +_SORT_KEYS = { + SORT_TITLE: lambda m: m.get("title", "").lower(), + SORT_YEAR: lambda m: m.get("year", 0), + SORT_RATING: lambda m: m.get("rating", 0.0), + SORT_RUNTIME: lambda m: m.get("runtime", 0), + SORT_DATE_ADDED: lambda m: m.get("dateadded", ""), +} + + +def select_and_sort_results( + movies: List[Dict[str, Any]], + count: int, + sort_by: int, + sort_dir: int, +) -> List[Dict[str, Any]]: + """Select N movies and sort them. + + For SORT_RANDOM: randomly sample N, then return in random order. + For other sorts: randomly sample N, then sort by the chosen key. + + Args: + movies: Pool of filtered movies to select from. + count: Maximum number of movies to return. + sort_by: Sort option constant (SORT_RANDOM, SORT_TITLE, etc). + sort_dir: Sort direction (SORT_ASC or SORT_DESC). + + Returns: + Selected and sorted list of movie dicts. + """ + # Sample from pool + if len(movies) <= count: + selected = list(movies) + else: + selected = random.sample(movies, count) + + # Sort + if sort_by == SORT_RANDOM: + random.shuffle(selected) + elif sort_by in _SORT_KEYS: + selected.sort( + key=_SORT_KEYS[sort_by], + reverse=(sort_dir == SORT_DESC), + ) + + log.debug("Results generated", event="results.generate", + pool_size=len(movies), result_count=len(selected), + sort_by=sort_by, sort_dir=sort_dir) + return selected diff --git a/script.easymovie/resources/lib/data/smart_playlists.py b/script.easymovie/resources/lib/data/smart_playlists.py new file mode 100644 index 000000000..184824070 --- /dev/null +++ b/script.easymovie/resources/lib/data/smart_playlists.py @@ -0,0 +1,55 @@ +""" +Smart playlist movie ID extraction. + +Queries a Kodi smart playlist (.xsp) file via Files.GetDirectory +and returns the set of movie IDs that match the playlist rules. + +Logging: + Logger: 'data' + Key events: + - pool.query (DEBUG): Playlist queried with result count + - pool.empty (WARNING): Playlist returned no movies + See LOGGING.md for full guidelines. +""" +import os +from typing import Set + +from resources.lib.data.queries import build_playlist_get_movies_query +from resources.lib.utils import get_logger, json_query + +log = get_logger('data') + + +def extract_movie_ids_from_playlist(playlist_path: str) -> Set[int]: + """Extract movie IDs from a smart playlist file. + + Normalizes the path to special:// format, queries Kodi via + Files.GetDirectory, and returns only items with type 'movie'. + + Args: + playlist_path: Path to the .xsp file (absolute or special://). + + Returns: + Set of Kodi movie IDs found in the playlist. + """ + # Normalize to special:// path (Kodi expects this format) + filename = os.path.basename(playlist_path) + clean_path = "special://profile/playlists/video/" + filename + + query = build_playlist_get_movies_query(clean_path) + result = json_query(query) + + files = result.get("files", []) + movie_ids = { + item["id"] for item in files + if item.get("type") == "movie" and "id" in item + } + + if movie_ids: + log.debug("Playlist pool queried", event="pool.query", + path=filename, movie_count=len(movie_ids)) + else: + log.warning("Playlist returned no movies", event="pool.empty", + path=filename) + + return movie_ids diff --git a/script.easymovie/resources/lib/data/storage.py b/script.easymovie/resources/lib/data/storage.py new file mode 100644 index 000000000..984120803 --- /dev/null +++ b/script.easymovie/resources/lib/data/storage.py @@ -0,0 +1,231 @@ +""" +Persistent storage for EasyMovie data. + +Manages suggested movie history, playback tracking, and +last-used filter answers. Data is stored as JSON in the +addon's userdata directory. + +Logging: + Logger: 'data' + Key events: + - history.validate (DEBUG): Stale entries removed + - history.prune (DEBUG): Old entries pruned + - history.clear (DEBUG): History cleared + - history.load_fail (WARNING): Storage file corrupt or unreadable + - history.save_fail (WARNING): Failed to save storage + See LOGGING.md for full guidelines. +""" +import json +import os +import time +from typing import Any, Dict, List, Optional, Set, Union + + +class StorageManager: + """Manages persistent JSON storage for EasyMovie. + + Data structure: + { + "suggested": [{"movieid": int, "timestamp": float}, ...], + "started": [{"movieid": int, "timestamp": float}, ...], + "last_filters": {...} + } + """ + + def __init__(self, path: str) -> None: + """Initialize storage, loading existing data if available. + + Args: + path: Full path to the JSON storage file. + """ + self._path = path + self._logger: Optional[Union[object, bool]] = None + self._data: Dict[str, Any] = { + "suggested": [], + "started": [], + "last_filters": {}, + } + self._load() + + @property + def _log(self) -> Optional[Any]: + """Lazy-init logger to avoid circular imports at module load time.""" + if self._logger is None: + try: + from resources.lib.utils import get_logger + self._logger = get_logger('data') + except Exception: + self._logger = False # Sentinel: don't retry + return self._logger if self._logger else None + + def _load(self) -> None: + """Load data from disk.""" + if os.path.exists(self._path): + try: + with open(self._path, "r", encoding="utf-8") as f: + loaded = json.load(f) + if isinstance(loaded, dict): + self._data["suggested"] = loaded.get("suggested", []) + self._data["started"] = loaded.get("started", []) + self._data["last_filters"] = loaded.get("last_filters", {}) + except (json.JSONDecodeError, IOError, OSError): + if self._log: + self._log.warning( + "Storage file corrupt or unreadable", + event="history.load_fail", + path=self._path) + + def save(self) -> None: + """Write data to disk atomically (write to temp, then replace).""" + try: + dir_path = os.path.dirname(self._path) + if dir_path: + os.makedirs(dir_path, exist_ok=True) + tmp_path = self._path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(self._data, f, indent=2) + os.replace(tmp_path, self._path) + except (IOError, OSError) as exc: + if self._log: + self._log.warning("Failed to save storage", + event="history.save_fail", + path=self._path, error=str(exc)) + + # ------------------------------------------------------------------ + # Internal helpers for suggested/started entry management + # ------------------------------------------------------------------ + + def _add_entry(self, key: str, movieid: int, title: str) -> None: + """Add a movie entry, avoiding duplicates.""" + existing_ids = {s.get("movieid", 0) for s in self._data[key]} + if movieid not in existing_ids: + self._data[key].append({ + "movieid": movieid, + "title": title, + "timestamp": time.time(), + }) + self.save() + + def _validate_entries( + self, key: str, movies: List[Dict[str, Any]], event: str, + ) -> None: + """Remove entries whose movie ID was reused for a different title. + + Kodi reuses movie IDs after deletion. By comparing stored titles + against current library titles, we detect and remove stale entries. + """ + title_by_id = {m.get("movieid", 0): m.get("title", "") for m in movies} + before = len(self._data[key]) + self._data[key] = [ + s for s in self._data[key] + if s.get("title") and s.get("movieid", 0) in title_by_id + and title_by_id[s.get("movieid", 0)] == s.get("title") + ] + after = len(self._data[key]) + if before != after: + if self._log: + self._log.debug( + "Stale %s entries removed" % key, + event=event, + removed=before - after, remaining=after) + self.save() + + # ------------------------------------------------------------------ + # Suggested movie tracking (re-suggestion avoidance) + # ------------------------------------------------------------------ + + def add_suggested(self, movieid: int, title: str = "") -> None: + """Record a movie as suggested (for re-suggestion avoidance). + + Args: + movieid: The Kodi movie ID. + title: Movie title (stored for debugging, not used in logic). + """ + self._add_entry("suggested", movieid, title) + + def get_suggested_ids(self) -> Set[int]: + """Get all suggested movie IDs.""" + return {s.get("movieid", 0) for s in self._data["suggested"]} + + def clear_suggested(self) -> None: + """Remove all suggested entries.""" + if self._data["suggested"]: + count = len(self._data["suggested"]) + self._data["suggested"] = [] + self.save() + if self._log: + self._log.debug( + "Suggested history cleared", + event="history.clear", removed=count) + + def validate_suggested(self, movies: List[Dict[str, Any]]) -> None: + """Remove suggested entries where the ID was reused for a different movie. + + Args: + movies: Current library movies (must include movieid and title). + """ + self._validate_entries("suggested", movies, "history.validate") + + def prune_suggested(self, max_age_hours: int) -> None: + """Remove suggested entries older than max_age_hours. + + Args: + max_age_hours: Maximum age in hours. + """ + cutoff = time.time() - (max_age_hours * 3600) + before = len(self._data["suggested"]) + self._data["suggested"] = [ + s for s in self._data["suggested"] + if s.get("timestamp", 0) >= cutoff + ] + after = len(self._data["suggested"]) + if before != after: + if self._log: + self._log.debug( + "Old suggested entries pruned", + event="history.prune", + removed=before - after, remaining=after, + max_age_hours=max_age_hours) + self.save() + + # ------------------------------------------------------------------ + # Started movie tracking (in-progress detection) + # ------------------------------------------------------------------ + + def add_started(self, movieid: int, title: str = "") -> None: + """Record a movie as started through EasyMovie. + + Args: + movieid: The Kodi movie ID. + title: Movie title (stored for debugging, not used in logic). + """ + self._add_entry("started", movieid, title) + + def get_started_ids(self) -> Set[int]: + """Get all started movie IDs.""" + return {s.get("movieid", 0) for s in self._data["started"]} + + def validate_started(self, movies: List[Dict[str, Any]]) -> None: + """Remove started entries for movies no longer in the library. + + Args: + movies: Current library movies (must include movieid and title). + """ + self._validate_entries("started", movies, "history.validate_started") + + # ------------------------------------------------------------------ + # Filter persistence + # ------------------------------------------------------------------ + + def save_last_filters(self, filters: Dict[str, Any]) -> None: + """Save wizard filter answers for next session. + + Args: + filters: Dict of filter answers. + """ + self._data["last_filters"] = filters + self.save() + + def load_last_filters(self) -> Dict[str, Any]: + """Load last wizard filter answers.""" + return self._data.get("last_filters", {}) diff --git a/script.easymovie/resources/lib/playback/__init__.py b/script.easymovie/resources/lib/playback/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/script.easymovie/resources/lib/playback/playback_monitor.py b/script.easymovie/resources/lib/playback/playback_monitor.py new file mode 100644 index 000000000..f1fb9d010 --- /dev/null +++ b/script.easymovie/resources/lib/playback/playback_monitor.py @@ -0,0 +1,293 @@ +""" +Playback monitor for movie set continuation. + +Monitors playback during playlist sessions and prompts +the user when a set-member movie finishes. + +Logging: + Logger: 'playback' + Key events: + - continuation.playback_ended (DEBUG): Playback ended callback received + - continuation.prompt (INFO): Showing continuation dialog + - continuation.accepted (INFO): User chose to watch next in set + - continuation.declined (INFO): User declined, continuing playlist + - continuation.fail (WARNING): No movieid for next movie in set + See LOGGING.md for full guidelines. +""" +from __future__ import annotations + +import threading +from typing import Any, Dict, Optional, cast + +import xbmc +import xbmcgui +import xbmcaddon + +from resources.lib.constants import ( + ACTION_NAV_BACK, + ACTION_PREVIOUS_MENU, + ADDON_ID, + CONTINUATION_DEFAULT_CONTINUE_SET, +) +from resources.lib.utils import get_logger, json_query, lang +from resources.lib.data.queries import build_add_movie_query +from resources.lib.data.movie_sets import get_next_in_set + +# Control IDs for the continuation dialog +CONT_HEADING = 1 +CONT_MESSAGE = 2 +CONT_TIMER = 3 +CONT_SUBTITLE = 4 +CONT_YES = 10 +CONT_NO = 11 +CONT_POSTER = 20 + +# Module-level logger +log = get_logger('playback') + + +class ContinuationDialog(xbmcgui.WindowXMLDialog): + """Countdown dialog for movie set continuation and set warning prompts. + + Args: + *args: Positional args passed to WindowXMLDialog. + **kwargs: Keyword args. Custom kwargs: + - message: str - Dialog message text + - subtitle: str - Secondary message text + - yes_label: str - Label for the Yes button (left) + - no_label: str - Label for the No button (right) + - duration: int - Countdown seconds (0 = no timer) + - heading: str - Static heading text (e.g. addon/clone name) + - timer_template: str - Timer format with %s for seconds + - default_yes: bool - True if Yes is the default on timeout + - poster: str - Optional poster image path + - addon_id: str - Addon ID for theming + - logger: StructuredLogger - Optional logger instance + """ + + def __new__(cls, *args, **kwargs): + """Create instance, filtering out custom kwargs for parent class.""" + for key in ('message', 'subtitle', 'yes_label', 'no_label', + 'duration', 'heading', 'timer_template', + 'default_yes', 'poster', 'addon_id', 'logger'): + kwargs.pop(key, None) + return super().__new__(cls, *args, **kwargs) + + def __init__(self, *args, **kwargs): + self._message = kwargs.pop('message', '') + self._subtitle = kwargs.pop('subtitle', '') + self._yes_label = kwargs.pop('yes_label', '') + self._no_label = kwargs.pop('no_label', '') + self._duration = kwargs.pop('duration', 0) + self._heading = kwargs.pop('heading', '') + self._timer_template = kwargs.pop('timer_template', 'Auto-selecting in %ss') + self._default_yes = kwargs.pop('default_yes', True) + self._poster = kwargs.pop('poster', '') + self._addon_id: str = kwargs.pop('addon_id', ADDON_ID) + self._log = kwargs.pop('logger', None) or log + super().__init__(*args, **kwargs) + self._closed = False + self._button_clicked: Optional[int] = None + self._timer_thread: Optional[threading.Thread] = None + + @property + def result(self) -> bool: + """Whether the affirmative action was chosen. + + Returns True if: + - User clicked Yes button, OR + - Timer expired and default_yes is True + + Returns False otherwise (No button, ESC, or timer expired with + default_yes False). + """ + if self._button_clicked == CONT_YES: + return True + if self._button_clicked == CONT_NO: + return False + return self._default_yes + + def onInit(self) -> None: + """Set up the dialog.""" + from resources.lib.ui import apply_theme + apply_theme(self, self._addon_id) + + heading = self._heading or xbmcaddon.Addon(self._addon_id).getAddonInfo('name') + cast(xbmcgui.ControlLabel, self.getControl(CONT_HEADING)).setLabel(heading) + cast(xbmcgui.ControlLabel, self.getControl(CONT_MESSAGE)).setLabel(self._message) + cast(xbmcgui.ControlLabel, self.getControl(CONT_SUBTITLE)).setLabel(self._subtitle) + cast(xbmcgui.ControlButton, self.getControl(CONT_YES)).setLabel(self._yes_label) + cast(xbmcgui.ControlButton, self.getControl(CONT_NO)).setLabel(self._no_label) + + if self._poster: + try: + cast(xbmcgui.ControlImage, self.getControl(CONT_POSTER)).setImage(self._poster) + except RuntimeError: + pass + + if self._duration > 0: + try: + cast(xbmcgui.ControlLabel, self.getControl(CONT_TIMER)).setLabel( + self._timer_template % self._duration + ) + except RuntimeError: + pass + + # Focus the non-default button + if self._default_yes: + self.setFocus(self.getControl(CONT_NO)) + else: + self.setFocus(self.getControl(CONT_YES)) + + # Start countdown + self._timer_thread = threading.Thread( + target=self._countdown_loop, daemon=True + ) + self._timer_thread.start() + else: + try: + cast(xbmcgui.ControlLabel, self.getControl(CONT_TIMER)).setLabel('') + except RuntimeError: + pass # Timer control not in this skin XML (e.g. set warning) + self.setFocus(self.getControl(CONT_YES)) + + def _countdown_loop(self) -> None: + """Countdown timer that auto-closes the dialog.""" + remaining = self._duration + while remaining > 0 and not self._closed: + xbmc.sleep(1000) + if self._closed: + return + remaining -= 1 + try: + cast(xbmcgui.ControlLabel, self.getControl(CONT_TIMER)).setLabel( + self._timer_template % remaining + ) + except RuntimeError: + return + + if not self._closed: + self._log.debug("Countdown expired", event="continuation.timeout") + self.close() + + def onClick(self, controlId: int) -> None: + """Handle button clicks.""" + if controlId in (CONT_YES, CONT_NO): + self._button_clicked = controlId + self._closed = True + self.close() + + def onAction(self, action: xbmcgui.Action) -> None: + """Handle back/escape.""" + if action.getId() in (ACTION_NAV_BACK, ACTION_PREVIOUS_MENU): + self._button_clicked = CONT_NO + self._closed = True + self.close() + + +class PlaybackMonitor(xbmc.Player): + """Monitors playback during playlist sessions for set continuation. + + Subclasses xbmc.Player to detect when a movie finishes playing. + When a set-member movie completes, checks for the next movie in + the set and shows a continuation prompt. + """ + + def __init__( + self, + set_cache: Dict[int, Dict[str, Any]], + movies: Dict[int, Dict[str, Any]], + continuation_duration: int = 20, + continuation_default: int = CONTINUATION_DEFAULT_CONTINUE_SET, + addon_id: str = ADDON_ID, + ) -> None: + super().__init__() + self._set_cache = set_cache + self._movies = movies # movieid -> movie dict + self._continuation_duration = continuation_duration + self._continuation_default = continuation_default + self._addon_id = addon_id + self._current_movie_id: Optional[int] = None + self._active = True + + def set_current_movie(self, movie_id: int) -> None: + """Set the currently playing movie ID.""" + self._current_movie_id = movie_id + + def stop_monitoring(self) -> None: + """Stop the monitor.""" + self._active = False + + def onPlayBackEnded(self) -> None: + """Called when playback ends naturally (movie finished).""" + log.debug("Playback ended callback", + event="continuation.playback_ended", + movie_id=self._current_movie_id, + active=self._active) + if not self._active or self._current_movie_id is None: + return + self._check_continuation() + + def _check_continuation(self) -> None: + """Check if we should prompt for set continuation.""" + movie_id = self._current_movie_id + if movie_id is None: + return + + movie = self._movies.get(movie_id) + if not movie: + return + + set_id = movie.get("setid", 0) + if not set_id or set_id not in self._set_cache: + return + + set_details = self._set_cache[set_id] + next_movie = get_next_in_set(set_details, movie_id) + if not next_movie: + return + + log.info("Showing continuation prompt", event="continuation.prompt", + finished_title=movie.get("title", ""), + next_title=next_movie.get("title", ""), + set_name=set_details.get("title", "")) + + # Show continuation dialog + addon_path = xbmcaddon.Addon(self._addon_id).getAddonInfo('path') + + finished_title = movie.get("title", "") + next_title = next_movie.get("title", "") + set_name = set_details.get("title", "") + + # Get poster art for next movie + next_art = next_movie.get("art", {}) + poster = next_art.get("poster", "") if isinstance(next_art, dict) else "" + + dialog = ContinuationDialog( + 'script-easymovie-continuation.xml', + addon_path, 'Default', '1080i', + message=f"{lang(32333)}[CR][B]{finished_title}[/B]", + subtitle=f"{lang(32332)} [B]{set_name}[/B]:[CR]{next_title}", + yes_label=lang(32330), + no_label=lang(32331), + poster=poster, + duration=self._continuation_duration, + default_yes=(self._continuation_default == CONTINUATION_DEFAULT_CONTINUE_SET), + heading=xbmcaddon.Addon(self._addon_id).getAddonInfo('name'), + addon_id=self._addon_id, + ) + dialog.doModal() + + if dialog.result: + log.info("Continuation accepted", event="continuation.accepted", + next_title=next_title) + # Insert next movie at front of playlist + next_id = next_movie.get("movieid", 0) + if next_id: + query = build_add_movie_query(next_id, position=0) + json_query(query, return_result=False) + else: + log.warning("No movieid for next movie in set", event="continuation.fail", + next_title=next_movie.get("title", "")) + else: + log.info("Continuation declined", event="continuation.declined") diff --git a/script.easymovie/resources/lib/playback/player.py b/script.easymovie/resources/lib/playback/player.py new file mode 100644 index 000000000..ac771004b --- /dev/null +++ b/script.easymovie/resources/lib/playback/player.py @@ -0,0 +1,101 @@ +""" +Movie playback controller. + +Handles single movie playback, resume points, and +now-playing info display. + +Logging: + Logger: 'playback' + Key events: + - playback.start (INFO): Movie playback started + - playback.resume (INFO): Resumed from position + See LOGGING.md for full guidelines. +""" +from __future__ import annotations + +from typing import Any, Dict, Optional, TYPE_CHECKING + +import xbmc + +if TYPE_CHECKING: + from resources.lib.data.storage import StorageManager + +from resources.lib.utils import get_logger, json_query +from resources.lib.data.queries import build_play_movie_query + +# Module-level logger +log = get_logger('playback') + + +def play_movie( + movie: Dict[str, Any], resume: bool = False, + storage: Optional['StorageManager'] = None, +) -> None: + """Play a single movie. + + Args: + movie: Movie dict with at minimum 'movieid' and 'title'. + resume: If True, resume from the last position. + storage: Optional StorageManager to record started movies. + """ + movie_id = movie.get("movieid", 0) + title = movie.get("title", "Unknown") + position = 0.0 + + # Record that EasyMovie started this movie (persists across restarts) + if storage and movie_id: + storage.add_started(movie_id, title) + + if resume: + resume_info = movie.get("resume", {}) + position = resume_info.get("position", 0) if isinstance(resume_info, dict) else 0 + if position > 0: + log.info("Resuming movie", event="playback.resume", + title=title, movieid=movie_id, + position_seconds=int(position)) + else: + resume = False + + if not resume: + log.info("Playing movie", event="playback.start", + title=title, movieid=movie_id) + + # Start playback via JSON-RPC + query = build_play_movie_query(movie_id) + json_query(query, return_result=False) + + # If resuming, seek to the saved position after playback starts + if resume: + # Wait briefly for the player to initialize + xbmc.sleep(500) + player = xbmc.Player() + if player.isPlaying(): + player.seekTime(position) + + +def get_resume_info(movie: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Get resume information for a movie. + + Args: + movie: Movie dict with resume data. + + Returns: + Dict with 'position' and 'total' keys, or None if no resume point. + """ + resume = movie.get("resume", {}) + if not isinstance(resume, dict): + return None + + position = resume.get("position", 0) + total = resume.get("total", 0) + + if position > 0 and total > 0: + remaining_seconds = int(total - position) + remaining_minutes = remaining_seconds // 60 + return { + "position": position, + "total": total, + "remaining_seconds": remaining_seconds, + "remaining_minutes": remaining_minutes, + } + return None diff --git a/script.easymovie/resources/lib/playback/playlist_builder.py b/script.easymovie/resources/lib/playback/playlist_builder.py new file mode 100644 index 000000000..ce66519d7 --- /dev/null +++ b/script.easymovie/resources/lib/playback/playlist_builder.py @@ -0,0 +1,130 @@ +""" +Playlist builder for movie marathon mode. + +Creates a Kodi video playlist from selected movies and +starts playback. + +Logging: + Logger: 'playback' + Key events: + - playlist.create (INFO): Playlist built + - playlist.start (INFO): Playback started + See LOGGING.md for full guidelines. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +import xbmc + +if TYPE_CHECKING: + from resources.lib.data.storage import StorageManager + +from resources.lib.constants import PLAYLIST_ADD_DELAY_MS +from resources.lib.utils import get_logger, json_query, notify +from resources.lib.data.queries import ( + get_clear_video_playlist_query, + build_add_movie_query, + build_play_playlist_query, +) + +# Module-level logger +log = get_logger('playback') + + +def build_and_play_playlist( + movies: List[Dict[str, Any]], + show_notifications: bool = True, + prioritize_in_progress: bool = False, + resume_from_position: bool = True, + storage: Optional['StorageManager'] = None, +) -> bool: + """Build a Kodi video playlist from movies and start playback. + + Args: + movies: List of movie dicts to add to the playlist. + show_notifications: Show progress notifications while building. + prioritize_in_progress: Sort partially-watched movies first. + resume_from_position: Resume movies from their last position. + storage: If provided, records movies as EasyMovie-started. + + Returns: + True if playlist was created and playback started. + """ + if not movies: + log.warning("No movies to build playlist", event="playlist.fail") + return False + + # Optionally sort in-progress movies first + if prioritize_in_progress: + movies = _sort_in_progress_first(movies) + + # Record all playlist movies as EasyMovie-started + if storage: + for movie in movies: + mid = movie.get("movieid", 0) + if mid: + storage.add_started(mid, movie.get("title", "")) + + # Clear existing video playlist + json_query(get_clear_video_playlist_query(), return_result=False) + + # Add movies one by one + total = len(movies) + for i, movie in enumerate(movies): + movie_id = movie.get("movieid", 0) + title = movie.get("title", "Unknown") + + if show_notifications: + notify(f"Building playlist... ({i + 1}/{total})") + + query = build_add_movie_query(movie_id) + json_query(query, return_result=False) + + log.debug("Added movie to playlist", + title=title, movieid=movie_id, + position=i) + + # Small delay between additions to avoid overwhelming Kodi + if i < total - 1: + xbmc.sleep(PLAYLIST_ADD_DELAY_MS) + + log.info("Playlist created", event="playlist.create", + movie_count=total, + titles=[m.get("title", "") for m in movies[:5]]) + + # Start playback + query = build_play_playlist_query(position=0) + json_query(query, return_result=False) + + log.info("Playlist playback started", event="playlist.start", + movie_count=total) + + return True + + +def _sort_in_progress_first( + movies: List[Dict[str, Any]] +) -> List[Dict[str, Any]]: + """Sort movies so partially-watched ones come first. + + Movies with a resume position are placed at the front, + maintaining their relative order. Unwatched movies follow. + + Args: + movies: List of movie dicts. + + Returns: + Reordered list with in-progress movies first. + """ + in_progress = [] + others = [] + + for movie in movies: + resume = movie.get("resume", {}) + if isinstance(resume, dict) and resume.get("position", 0) > 0: + in_progress.append(movie) + else: + others.append(movie) + + return in_progress + others diff --git a/script.easymovie/resources/lib/service/__init__.py b/script.easymovie/resources/lib/service/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/script.easymovie/resources/lib/service/main.py b/script.easymovie/resources/lib/service/main.py new file mode 100644 index 000000000..1f94d389c --- /dev/null +++ b/script.easymovie/resources/lib/service/main.py @@ -0,0 +1,138 @@ +""" +EasyMovie background service. + +Lightweight service that monitors movie playback for set awareness. +No daemon loop — just a playback monitor and abort wait. + +Logging: + Logger: 'service' + Key events: + - service.start (INFO): Service started with version, device, Kodi build + - service.stop (INFO): Service stopping + - icon.restored (INFO): Custom icon restored after addon upgrade + See LOGGING.md for full guidelines. +""" +import filecmp +import os +import socket + +import xbmc +import xbmcaddon +import xbmcvfs + +from resources.lib.constants import CUSTOM_ICON_BACKUP +from resources.lib.utils import get_logger, invalidate_icon_cache +from resources.lib.service.playback_monitor import MoviePlaybackMonitor + + +def _get_device_name() -> str: + """Return the Kodi device friendly name, falling back to hostname.""" + try: + name = xbmc.getInfoLabel('System.FriendlyName') + if name: + return name + return socket.gethostname() or 'unknown' + except Exception: + return 'unknown' + + +def _restore_icon_if_needed(addon: xbmcaddon.Addon) -> None: + """Restore custom icon after an addon upgrade overwrites icon.png. + + Checks if the user had a custom icon choice and whether the current + icon.png matches icon_default.png (indicating an upgrade replaced it). + If so, restores from the backup in addon_data. + """ + log = get_logger('service') + addon_id = addon.getAddonInfo('id') + icon_choice = addon.getSetting('icon_choice') + if not icon_choice: + return + + addon_path = addon.getAddonInfo('path') + icon_path = os.path.join(addon_path, 'icon.png') + default_path = os.path.join(addon_path, 'icon_default.png') + + if not os.path.isfile(icon_path) or not os.path.isfile(default_path): + return + + if not filecmp.cmp(icon_path, default_path, shallow=False): + return # Icon is already custom, no restore needed + + # Icon matches default — upgrade wiped it. Try to restore. + addon_data = xbmcvfs.translatePath( + f'special://profile/addon_data/{addon_id}/' + ) + backup_path = os.path.join(addon_data, CUSTOM_ICON_BACKUP) + + if os.path.isfile(backup_path): + xbmcvfs.copy(backup_path, icon_path) + invalidate_icon_cache(addon_id) + log.info("Custom icon restored after upgrade", + event="icon.restored", source="backup", addon_id=addon_id) + return + + # No backup — try built-in fallback + if icon_choice.startswith('built-in:'): + filename = icon_choice.split(':', 1)[1] + builtin_path = os.path.join(addon_path, 'resources', 'icons', filename) + if os.path.isfile(builtin_path): + xbmcvfs.copy(builtin_path, icon_path) + # Re-create the missing backup + xbmcvfs.copy(builtin_path, backup_path) + invalidate_icon_cache(addon_id) + log.info("Custom icon restored after upgrade", + event="icon.restored", source="built-in", + icon=filename, addon_id=addon_id) + return + + # Custom image with no backup — can't restore + log.warning("Custom icon backup missing, resetting to default", + event="icon.restore_failed", choice=icon_choice, + addon_id=addon_id) + addon.setSetting('icon_choice', '') + + +def _get_kodi_version() -> str: + """Return the Kodi build version (first word only).""" + try: + build = xbmc.getInfoLabel('System.BuildVersion') + if build: + return build.split()[0] + return 'unknown' + except Exception: + return 'unknown' + + +def main() -> None: + """Run the EasyMovie background service.""" + addon = xbmcaddon.Addon() + version = addon.getAddonInfo('version') + + log = get_logger('service') + log.info( + "EasyMovie service started", + event="service.start", + version=version, + device=_get_device_name(), + kodi=_get_kodi_version(), + ) + + _restore_icon_if_needed(addon) + + monitor = xbmc.Monitor() + # Must keep reference to prevent garbage collection — Kodi calls + # the Player subclass callbacks as long as the object is alive. + _player = MoviePlaybackMonitor() + + while not monitor.abortRequested(): + if monitor.waitForAbort(1): + break + + del _player # Explicit cleanup before service exit + log.info( + "EasyMovie service stopping", + event="service.stop", + version=version, + device=_get_device_name(), + ) diff --git a/script.easymovie/resources/lib/service/playback_monitor.py b/script.easymovie/resources/lib/service/playback_monitor.py new file mode 100644 index 000000000..139e198d3 --- /dev/null +++ b/script.easymovie/resources/lib/service/playback_monitor.py @@ -0,0 +1,230 @@ +""" +Background playback monitor for movie set awareness. + +Monitors all movie playback in Kodi. When a user plays a movie +that belongs to a set with earlier unwatched entries, pauses +playback and offers to play the earlier movie instead. + +Skips the check when playback was initiated by EasyMovie +(which handles set ordering via substitution). + +Logging: + Logger: 'service' + Key events: + - setcheck.found (INFO): Earlier unwatched movie found + - setcheck.accepted (INFO): User chose to switch + - setcheck.declined (INFO): User chose to continue + - setcheck.skip (DEBUG): Check skipped (reason logged) + - setcheck.error (ERROR): Query or dialog error + See LOGGING.md for full guidelines. +""" +from __future__ import annotations + +from typing import Any, Dict + +import xbmc +import xbmcaddon +import xbmcgui + +from resources.lib.constants import ( + ADDON_ID, + PLAYER_STOP_DELAY_MS, + PROP_PLAYLIST_RUNNING, +) +from resources.lib.utils import get_bool_setting, get_logger, json_query, lang +from resources.lib.data.queries import ( + get_movie_details_with_art_query, + get_movie_set_details_query, +) +from resources.lib.data.movie_sets import find_first_unwatched_before +from resources.lib.playback.playback_monitor import ContinuationDialog + +log = get_logger('service') + + +class MoviePlaybackMonitor(xbmc.Player): + """Monitors playback for movie set awareness. + + Uses onAVStarted with xbmc.Player().getVideoInfoTag() to get + the playing movie's database ID and media type immediately + (no JSON-RPC timing issues), then queries GetMovieDetails + for set membership. + """ + + def onAVStarted(self) -> None: + """Handle AV stream start — check for earlier unwatched set movies.""" + try: + self._check_set_awareness() + except Exception: + log.exception("Set check failed", event="setcheck.error") + + def _check_set_awareness(self) -> None: + """Run the set awareness check.""" + # Check setting + if not get_bool_setting('previous_movie_check'): + log.debug("Set check disabled by setting", event="setcheck.skip") + return + + # Skip EasyMovie-initiated playback + window = xbmcgui.Window(10000) + if window.getProperty(PROP_PLAYLIST_RUNNING) == 'true': + log.debug("EasyMovie session active, skipping", + event="setcheck.skip") + return + + # Get playing item info from the player directly (no JSON-RPC delay) + try: + info_tag = self.getVideoInfoTag() + media_type = info_tag.getMediaType() + movie_id = info_tag.getDbId() + except RuntimeError: + log.debug("No video info tag available", event="setcheck.skip") + return + + if media_type != 'movie': + log.debug("Not a movie", event="setcheck.skip", + media_type=media_type) + return + + if not movie_id: + log.debug("No movie ID", event="setcheck.skip") + return + + # Query movie details for set membership + movie_result = json_query( + get_movie_details_with_art_query(movie_id), return_result=True + ) + if not movie_result: + log.debug("No movie details returned", event="setcheck.skip") + return + + movie_details = movie_result.get("moviedetails", movie_result) + set_id = movie_details.get("setid", 0) + if not set_id or set_id <= 0: + log.debug("Movie not in a set", event="setcheck.skip") + return + + # Query set details + log.debug("Querying set details", event="setcheck.query", + set_id=set_id) + set_result = json_query( + get_movie_set_details_query(set_id), return_result=True + ) + if not set_result: + log.debug("No set details returned", event="setcheck.skip") + return + + set_details = set_result.get("setdetails", set_result) + + # Check for earlier unwatched + earlier = find_first_unwatched_before(set_details, movie_id) + if earlier is None: + log.debug("No earlier unwatched movie", event="setcheck.skip", + set_name=movie_details.get('set', '')) + return + + # Found an earlier unwatched movie + earlier_title = earlier.get("title", "") + earlier_year = str(earlier.get("year", "")) + set_name = movie_details.get("set", set_details.get("title", "")) + + log.info("Earlier unwatched movie found", + event="setcheck.found", + current_title=movie_details.get('title', ''), + earlier_title=earlier_title, + set_name=set_name) + + self._show_set_warning(earlier, earlier_title, earlier_year, set_name) + + def _show_set_warning( + self, + earlier_movie: Dict[str, Any], + earlier_title: str, + earlier_year: str, + set_name: str, + ) -> None: + """Pause playback and show dialog for earlier unwatched movie.""" + # Pause playback + try: + xbmc.executeJSONRPC( + '{"jsonrpc":"2.0","method":"Player.PlayPause",' + '"params":{"playerid":1,"play":false},"id":1}' + ) + except Exception: + log.exception("Failed to pause playback", event="setcheck.error") + return + + # Build dialog + addon_path = xbmcaddon.Addon(ADDON_ID).getAddonInfo('path') + + # Get poster art + art = earlier_movie.get("art", {}) + poster = art.get("poster", "") if isinstance(art, dict) else "" + + dialog = ContinuationDialog( + 'script-easymovie-setwarning.xml', + addon_path, 'Default', '1080i', + message=( + f"[B]{earlier_title}[/B] ({earlier_year})[CR]" + f"{lang(32340)} [B]{set_name}[/B][CR]" + f"{lang(32341)}" + ), + subtitle=lang(32342), + yes_label=lang(32300), + no_label=lang(32301), + poster=poster, + duration=0, + default_yes=True, + heading=xbmcaddon.Addon(ADDON_ID).getAddonInfo('name'), + addon_id=ADDON_ID, + ) + dialog.doModal() + + if dialog.result: + log.info("User chose earlier movie", + event="setcheck.accepted", + earlier_title=earlier_title, + set_name=set_name) + self._play_earlier_movie(earlier_movie) + else: + log.info("User declined, continuing", + event="setcheck.declined", + earlier_title=earlier_title, + set_name=set_name) + self._unpause() + + def _play_earlier_movie(self, movie: Dict[str, Any]) -> None: + """Stop current playback and start the earlier movie.""" + movie_id = movie.get("movieid", 0) + if not movie_id: + log.warning("No movieid for earlier movie", + event="setcheck.error") + self._unpause() + return + + try: + xbmc.executeJSONRPC( + '{"jsonrpc":"2.0","method":"Player.Stop",' + '"params":{"playerid":1},"id":1}' + ) + xbmc.sleep(PLAYER_STOP_DELAY_MS) + xbmc.executeJSONRPC( + '{"jsonrpc":"2.0","method":"Player.Open",' + '"params":{"item":{"movieid":%d},' + '"options":{"resume":true}},"id":1}' % movie_id + ) + except Exception: + log.exception("Failed to start earlier movie", + event="setcheck.error") + + @staticmethod + def _unpause() -> None: + """Resume paused playback.""" + try: + xbmc.executeJSONRPC( + '{"jsonrpc":"2.0","method":"Player.PlayPause",' + '"params":{"playerid":1,"play":true},"id":1}' + ) + except Exception: + log.exception("Failed to unpause playback", + event="setcheck.error") diff --git a/script.easymovie/resources/lib/ui/__init__.py b/script.easymovie/resources/lib/ui/__init__.py new file mode 100644 index 000000000..bce975ab1 --- /dev/null +++ b/script.easymovie/resources/lib/ui/__init__.py @@ -0,0 +1,33 @@ +""" +UI package initialization. + +Provides theme application for all EasyMovie windows. +""" +from __future__ import annotations + +from typing import Optional + +import xbmcgui + +from resources.lib.constants import THEME_COLORS + + +def apply_theme(window: xbmcgui.WindowXMLDialog, addon_id: Optional[str] = None) -> None: + """Apply theme colors as window properties on the given dialog. + + Must be called in each dialog's onInit() so $INFO[Window.Property(...)] + resolves correctly against the current window. + + Args: + window: The dialog window to set color properties on. + addon_id: Optional addon ID (for clone support). + """ + import xbmcaddon + addon = xbmcaddon.Addon(addon_id) if addon_id else xbmcaddon.Addon() + try: + theme = int(addon.getSetting('theme') or '0') + except (ValueError, TypeError): + theme = 0 + colors = THEME_COLORS.get(theme, THEME_COLORS[0]) + for prop, value in colors.items(): + window.setProperty(prop, value) diff --git a/script.easymovie/resources/lib/ui/browse_window.py b/script.easymovie/resources/lib/ui/browse_window.py new file mode 100644 index 000000000..7a1328d28 --- /dev/null +++ b/script.easymovie/resources/lib/ui/browse_window.py @@ -0,0 +1,246 @@ +""" +Browse window for displaying movie results. + +Supports 5 viewing modes via different XML files. +Handles movie selection, Surprise Me, Re-roll, and context menu. + +Logging: + Logger: 'browse' + Key events: + - ui.browse (INFO): Browse window opened + - ui.select (INFO): Movie selected by user + - ui.surprise (INFO): Surprise Me triggered + - ui.reroll (INFO): Re-roll triggered + - ui.browse_close (DEBUG): User closed browse window + See LOGGING.md for full guidelines. +""" +from __future__ import annotations + +from typing import Dict, List, Any, Optional, cast + +import xbmcgui +import xbmcaddon + +from resources.lib.constants import ( + ACTION_CONTEXT_MENU, + ACTION_NAV_BACK, + ACTION_PREVIOUS_MENU, + ACTION_TELETEXT_BLUE, + ADDON_ID, + THEME_COLORS, + THEME_NAMES, + VIEW_SHOWCASE, + VIEW_CARD_LIST, + VIEW_POSTERS, + VIEW_BIG_SCREEN, + VIEW_SPLIT_VIEW, +) +from resources.lib.utils import get_logger + +# Control IDs (shared across all view XMLs) +LIST_CONTROL_ID = 655 +SURPRISE_BUTTON_ID = 10 +REROLL_BUTTON_ID = 11 + +# Result signals +RESULT_REROLL = "__reroll__" +RESULT_SURPRISE = "__surprise__" +# View style to XML filename mapping +VIEW_XML_MAP = { + VIEW_SHOWCASE: "script-easymovie-postergrid.xml", + VIEW_CARD_LIST: "script-easymovie-cardlist.xml", + VIEW_POSTERS: "script-easymovie-main.xml", + VIEW_BIG_SCREEN: "script-easymovie-BigScreenList.xml", + VIEW_SPLIT_VIEW: "script-easymovie-splitlist.xml", +} + +# Module-level logger +log = get_logger('browse') + + +class BrowseWindow(xbmcgui.WindowXMLDialog): + """Browse window for displaying and selecting movies. + + Supports all 5 viewing modes via different XML files. + The same control IDs are used across all views. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._movies: List[Dict[str, Any]] = [] + self._result: Optional[Any] = None + self._addon_id: str = ADDON_ID + self._preview_mode: bool = False + self._theme_index: int = 0 + + def set_movies(self, movies: List[Dict[str, Any]]) -> None: + """Set the movies to display.""" + self._movies = movies + + def set_addon_id(self, addon_id: str) -> None: + """Set the addon ID (for clone support).""" + self._addon_id = addon_id + + def set_preview_mode(self, theme_index: int) -> None: + """Enable preview mode with live theme cycling via blue button.""" + self._preview_mode = True + self._theme_index = theme_index + + @property + def result(self) -> Optional[Any]: + """The result after the window closes. + + Returns: + - A movie dict if user selected a movie + - RESULT_REROLL if user pressed Re-roll + - RESULT_SURPRISE if user pressed Surprise Me + - None if user closed/backed out + """ + return self._result + + def onInit(self): + """Populate the list when the window opens.""" + from resources.lib.ui import apply_theme + apply_theme(self, self._addon_id) + + # Set addon name for skin heading (shows clone name for clones) + self.setProperty( + 'EasyMovie.AddonName', + xbmcaddon.Addon(self._addon_id).getAddonInfo('name'), + ) + + log.info("Browse window opened", event="ui.browse", + movie_count=len(self._movies)) + + list_control = cast(xbmcgui.ControlList, self.getControl(LIST_CONTROL_ID)) + list_control.reset() + + for movie in self._movies: + li = xbmcgui.ListItem(movie.get("title", "")) + + # Set video info via InfoTagVideo (Kodi 21+) + info_tag = li.getVideoInfoTag() + info_tag.setTitle(movie.get("title", "")) + info_tag.setYear(movie.get("year", 0)) + info_tag.setGenres(movie.get("genre", [])) + info_tag.setRating(movie.get("rating", 0.0)) + info_tag.setDuration(movie.get("runtime", 0)) + info_tag.setPlot(movie.get("plot", "")) + info_tag.setMpaa(movie.get("mpaa", "")) + + # Set art + art = movie.get("art", {}) + if art: + li.setArt({ + 'poster': art.get("poster", ""), + 'fanart': art.get("fanart", ""), + 'thumb': art.get("poster", ""), + }) + + # Set custom properties + runtime_secs = movie.get("runtime", 0) + minutes = runtime_secs // 60 + if minutes >= 60: + li.setProperty("runtime_min", f"{minutes // 60}h {minutes % 60}m") + else: + li.setProperty("runtime_min", f"{minutes}m") + + set_name = movie.get("set", "") + if set_name: + li.setProperty("set_name", set_name) + + li.setProperty("movieid", str(movie.get("movieid", 0))) + + if movie.get("playcount", 0) > 0: + li.setProperty("watched", "true") + + list_control.addItem(li) + + if self._movies: + self.setFocusId(LIST_CONTROL_ID) + + def onClick(self, controlId): + """Handle control clicks.""" + if controlId == LIST_CONTROL_ID: + list_control = cast(xbmcgui.ControlList, self.getControl(LIST_CONTROL_ID)) + idx = list_control.getSelectedPosition() + if 0 <= idx < len(self._movies): + self._result = self._movies[idx] + log.info("Movie selected", event="ui.select", + title=self._movies[idx].get("title", ""), + movieid=self._movies[idx].get("movieid", 0)) + self.close() + + elif controlId == SURPRISE_BUTTON_ID: + log.info("Surprise Me pressed", event="ui.surprise") + self._result = RESULT_SURPRISE + self.close() + + elif controlId == REROLL_BUTTON_ID: + log.info("Re-roll pressed", event="ui.reroll") + self._result = RESULT_REROLL + self.close() + + def _get_focused_movie(self) -> Optional[Dict[str, Any]]: + """Get the currently focused movie, or None.""" + list_control = cast(xbmcgui.ControlList, self.getControl(LIST_CONTROL_ID)) + idx = list_control.getSelectedPosition() + if 0 <= idx < len(self._movies): + return self._movies[idx] + return None + + def onAction(self, action): + """Handle navigation actions.""" + action_id = action.getId() + if action_id in (ACTION_NAV_BACK, ACTION_PREVIOUS_MENU): + log.debug("Browse window closed by user", event="ui.browse_close") + self._result = None + self.close() + elif self._preview_mode and ( + action_id == ACTION_TELETEXT_BLUE + or action.getButtonCode() == 0xF054 + ): + self._theme_index = (self._theme_index + 1) % len(THEME_COLORS) + for prop, value in THEME_COLORS[self._theme_index].items(): + self.setProperty(prop, value) + xbmcgui.Dialog().notification( + "Theme", THEME_NAMES[self._theme_index], time=1500) + elif action_id == ACTION_CONTEXT_MENU: + movie = self._get_focused_movie() + if movie: + from resources.lib.ui.context_menu import ( + show_context_menu, CONTEXT_PLAY, CONTEXT_PLAY_SET, + ) + result = show_context_menu(movie, self._addon_id) + if result == CONTEXT_PLAY: + self._result = movie + self.close() + elif result == CONTEXT_PLAY_SET: + self._result = {"__play_set__": True, "movie": movie} + self.close() + + +def show_browse_window( + movies: List[Dict[str, Any]], + view_style: int, + addon_id: str = ADDON_ID, +) -> Optional[Any]: + """Show the browse window with the specified view style. + + Args: + movies: List of movie dicts (with art) to display. + view_style: View style constant (VIEW_SHOWCASE, etc.) + addon_id: Addon ID (for clone support). + + Returns: + Selected movie dict, RESULT_REROLL, RESULT_SURPRISE, or None. + """ + xml_file = VIEW_XML_MAP.get(view_style, VIEW_XML_MAP[VIEW_SHOWCASE]) + addon_path = xbmcaddon.Addon(addon_id).getAddonInfo('path') + + window = BrowseWindow(xml_file, addon_path, 'Default', '1080i') + window.set_movies(movies) + window.set_addon_id(addon_id) + window.doModal() + + return window.result diff --git a/script.easymovie/resources/lib/ui/context_menu.py b/script.easymovie/resources/lib/ui/context_menu.py new file mode 100644 index 000000000..e7e22f511 --- /dev/null +++ b/script.easymovie/resources/lib/ui/context_menu.py @@ -0,0 +1,116 @@ +""" +Context menu for the EasyMovie browse window. + +Options: +- Play: Play the selected movie +- Play Full Set: Play all movies in the set (hidden if not in a set) + +Logging: + Logger: 'ui' + Key events: + - ui.context_open (DEBUG): Context menu opened + - ui.context_select (DEBUG): Option selected + See LOGGING.md for full guidelines. +""" +from __future__ import annotations + +from typing import Any, Dict, Optional, cast + +import xbmcgui + +from resources.lib.constants import ACTION_NAV_BACK, ACTION_PREVIOUS_MENU, ADDON_ID +from resources.lib.utils import get_logger, lang + +# Context menu action constants +CONTEXT_PLAY = "play" +CONTEXT_PLAY_SET = "play_set" + +# Control IDs matching the XML +_BUTTON_PLAY = 110 +_BUTTON_PLAY_SET = 120 + +# Module-level logger +log = get_logger('ui') + + +class ContextMenuWindow(xbmcgui.WindowXMLDialog): + """Themed context menu dialog for the browse window.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._addon_id: str = ADDON_ID + self._has_set: bool = False + self._set_name: str = "" + self._result: Optional[str] = None + + @property + def result(self) -> Optional[str]: + """The selected action, or None if cancelled.""" + return self._result + + def onInit(self): + """Set up button labels and visibility.""" + from resources.lib.ui import apply_theme + apply_theme(self, self._addon_id) + + log.debug("Context menu opened", event="ui.context_open", + has_set=self._has_set) + + cast(xbmcgui.ControlButton, self.getControl(_BUTTON_PLAY)).setLabel( + lang(32312)) # "Play" + + set_label = lang(32313) # "Play Full Set" + cast(xbmcgui.ControlButton, self.getControl(_BUTTON_PLAY_SET)).setLabel( + set_label) + + # Hide "Play Full Set" if movie is not in a set + if not self._has_set: + self.getControl(_BUTTON_PLAY_SET).setVisible(False) + + self.setFocus(self.getControl(_BUTTON_PLAY)) + + def onClick(self, controlId): + """Handle button clicks.""" + if controlId == _BUTTON_PLAY: + self._result = CONTEXT_PLAY + elif controlId == _BUTTON_PLAY_SET: + self._result = CONTEXT_PLAY_SET + + log.debug("Context option selected", event="ui.context_select", + result=self._result) + self.close() + + def onAction(self, action): + """Handle back/escape.""" + action_id = action.getId() + if action_id in (ACTION_NAV_BACK, ACTION_PREVIOUS_MENU): + self.close() + + +def show_context_menu( + movie: Dict[str, Any], + addon_id: Optional[str] = None, +) -> Optional[str]: + """Show the themed context menu for a movie. + + Args: + movie: The movie dict for the focused item. + addon_id: Optional addon ID (for clone support). + + Returns: + CONTEXT_PLAY, CONTEXT_PLAY_SET, or None. + """ + import xbmcaddon + addon_path = xbmcaddon.Addon(addon_id or ADDON_ID).getAddonInfo('path') + + dialog = ContextMenuWindow( + 'script-easymovie-contextwindow.xml', + addon_path, 'Default', '1080i' + ) + dialog._addon_id = addon_id or ADDON_ID + set_name = movie.get("set", "") + dialog._has_set = bool(set_name) + dialog._set_name = set_name + dialog.doModal() + + return dialog.result diff --git a/script.easymovie/resources/lib/ui/dialogs.py b/script.easymovie/resources/lib/ui/dialogs.py new file mode 100644 index 000000000..34ffad030 --- /dev/null +++ b/script.easymovie/resources/lib/ui/dialogs.py @@ -0,0 +1,284 @@ +""" +EasyMovie Dialog Helpers. + +Provides dialog classes and helper functions for the wizard +and general-purpose dialogs. + +Logging: + Logger: 'ui' + Key events: + - ui.dialog_open (DEBUG): Dialog opened + - ui.dialog_select (DEBUG): User made selection + - ui.dialog_cancel (DEBUG): User cancelled dialog + See LOGGING.md for full guidelines. +""" +from __future__ import annotations + +from typing import List, Optional, Set, cast + +import xbmcgui + +from resources.lib.constants import ( + ACTION_MOVE_DOWN, + ACTION_MOVE_UP, + ACTION_NAV_BACK, + ACTION_PREVIOUS_MENU, + ADDON_ID, +) +from resources.lib.utils import get_logger + +# Control IDs matching the XML files +SELECT_HEADING = 1 +SELECT_LIST = 100 +SELECT_OK = 10 +SELECT_BACK = 11 + +CONFIRM_HEADING = 1 +CONFIRM_MESSAGE = 2 +CONFIRM_YES = 10 +CONFIRM_NO = 11 + +# Module-level logger +log = get_logger('ui') + + +def _get_addon_path() -> str: + """Get the addon root path (Kodi resolves the skin subdirectory).""" + import xbmcaddon + return xbmcaddon.Addon(ADDON_ID).getAddonInfo('path') + + +class SelectDialog(xbmcgui.WindowXMLDialog): + """Multi-purpose selection dialog with checkbox support.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._addon_id: str = ADDON_ID + self.heading = "" + self.items: List[str] = [] + self.preselected: List[int] = [] + self.multi_select = True + self.headers: Set[int] = set() + self.selected: List[int] = [] + self.cancelled = False + self._back_pressed = False + + def onInit(self): + """Populate the dialog when it opens.""" + from resources.lib.ui import apply_theme + apply_theme(self, self._addon_id) + + cast(xbmcgui.ControlLabel, self.getControl(SELECT_HEADING)).setLabel(self.heading) + + list_control = cast(xbmcgui.ControlList, self.getControl(SELECT_LIST)) + list_control.reset() + + for i, item_label in enumerate(self.items): + li = xbmcgui.ListItem(item_label) + if i in self.headers: + li.setProperty('is_header', 'true') + elif i in self.preselected: + li.setProperty('checked', 'true') + self.selected.append(i) + list_control.addItem(li) + + # Single-select: set property so XML hides checkboxes and OK button + if not self.multi_select: + self.setProperty('EasyMovie.SingleSelect', 'true') + + if self.items: + self.setFocusId(SELECT_LIST) + + def onClick(self, controlId): + """Handle button and list item clicks.""" + if controlId == SELECT_LIST: + list_control = cast(xbmcgui.ControlList, self.getControl(SELECT_LIST)) + if self.multi_select: + idx = list_control.getSelectedPosition() + li = list_control.getSelectedItem() + if li and li.getProperty('is_header') == 'true': + return + if li.getProperty('checked') == 'true': + li.setProperty('checked', '') + if idx in self.selected: + self.selected.remove(idx) + else: + li.setProperty('checked', 'true') + if idx not in self.selected: + self.selected.append(idx) + else: + # Single select: close immediately (skip headers) + li = list_control.getSelectedItem() + if li and li.getProperty('is_header') == 'true': + return + self.selected = [list_control.getSelectedPosition()] + self.close() + + elif controlId == SELECT_OK: + self.close() + + elif controlId == SELECT_BACK: + self._back_pressed = True + self.cancelled = True + self.close() + + def onAction(self, action): + """Handle back/escape and skip header items on navigation.""" + action_id = action.getId() + if action_id in (ACTION_NAV_BACK, ACTION_PREVIOUS_MENU): + self._back_pressed = True + self.cancelled = True + self.close() + elif action_id in (ACTION_MOVE_UP, ACTION_MOVE_DOWN) and self.headers: + list_control = cast(xbmcgui.ControlList, self.getControl(SELECT_LIST)) + pos = list_control.getSelectedPosition() + if pos in self.headers: + step = 1 if action_id == ACTION_MOVE_DOWN else -1 + new_pos = pos + step + # Clamp to valid range + new_pos = max(0, min(new_pos, list_control.size() - 1)) + if new_pos not in self.headers: + list_control.selectItem(new_pos) + + @property + def back_pressed(self) -> bool: + """Whether the user pressed back (vs OK or item select).""" + return self._back_pressed + + +class ConfirmDialog(xbmcgui.WindowXMLDialog): + """Simple yes/no confirmation dialog.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._addon_id: str = ADDON_ID + self.heading = "" + self.message = "" + self.yes_label = "" + self.no_label = "" + self.confirmed = False + self.cancelled = False + + def onInit(self): + """Set up the dialog labels.""" + from resources.lib.ui import apply_theme + apply_theme(self, self._addon_id) + + cast(xbmcgui.ControlLabel, self.getControl(CONFIRM_HEADING)).setLabel(self.heading) + cast(xbmcgui.ControlLabel, self.getControl(CONFIRM_MESSAGE)).setLabel(self.message) + if self.yes_label: + cast(xbmcgui.ControlButton, self.getControl(CONFIRM_YES)).setLabel(self.yes_label) + if self.no_label: + cast(xbmcgui.ControlButton, self.getControl(CONFIRM_NO)).setLabel(self.no_label) + else: + self.setProperty('EasyMovie.SingleButton', 'true') + self.setFocus(self.getControl(CONFIRM_YES)) + + def onClick(self, controlId): + """Handle button clicks.""" + if controlId == CONFIRM_YES: + self.confirmed = True + self.close() + elif controlId == CONFIRM_NO: + self.confirmed = False + self.close() + + def onAction(self, action): + """Handle back/escape.""" + action_id = action.getId() + if action_id in (ACTION_NAV_BACK, ACTION_PREVIOUS_MENU): + self.cancelled = True + self.close() + + +def show_select_dialog( + heading: str, + items: List[str], + multi_select: bool = True, + preselected: Optional[List[int]] = None, + addon_id: Optional[str] = None, + headers: Optional[Set[int]] = None, +) -> Optional[List[int]]: + """Show a themed selection dialog. + + Args: + heading: Dialog heading text. + items: List of item labels. + multi_select: If True, checkboxes. If False, single-select closes on pick. + preselected: Indices of pre-selected items. + addon_id: Optional addon ID (for clone support). + headers: Indices of non-selectable group header items. + + Returns: + List of selected indices, or None if cancelled/back pressed. + """ + log.debug("Select dialog opened", event="ui.dialog_open", + heading=heading, item_count=len(items), + multi_select=multi_select) + dialog = SelectDialog( + 'script-easymovie-select.xml', + _get_addon_path(), + 'Default', '1080i' + ) + dialog._addon_id = addon_id or ADDON_ID + dialog.heading = heading + dialog.items = items + dialog.multi_select = multi_select + dialog.preselected = preselected or [] + dialog.headers = headers or set() + dialog.doModal() + + if dialog.cancelled: + log.debug("Select dialog cancelled", event="ui.dialog_cancel", + heading=heading) + return None + selected = sorted(dialog.selected) + if not selected: + log.debug("Select dialog empty selection", event="ui.dialog_empty", + heading=heading) + return [] # OK with nothing selected = no filter + log.debug("Select dialog selection made", event="ui.dialog_select", + heading=heading, selected_count=len(selected)) + return selected + + +def show_confirm_dialog( + heading: str, + message: str, + yes_label: str = "", + no_label: str = "", + addon_id: Optional[str] = None, +) -> Optional[bool]: + """Show a themed confirmation dialog. + + Args: + heading: Dialog heading text. + message: Message body. + yes_label: Custom label for the yes button. + no_label: Custom label for the no button. + addon_id: Optional addon ID (for clone support). + + Returns: + True if user confirmed, False if declined, None if cancelled/back. + """ + log.debug("Confirm dialog opened", event="ui.dialog_open", + heading=heading) + dialog = ConfirmDialog( + 'script-easymovie-confirm.xml', + _get_addon_path(), + 'Default', '1080i' + ) + dialog._addon_id = addon_id or ADDON_ID + dialog.heading = heading + dialog.message = message + dialog.yes_label = yes_label + dialog.no_label = no_label + dialog.doModal() + + if dialog.cancelled: + log.debug("Confirm dialog cancelled", event="ui.dialog_cancel", + heading=heading) + return None + log.debug("Confirm dialog result", event="ui.dialog_select", + heading=heading, confirmed=dialog.confirmed) + return dialog.confirmed diff --git a/script.easymovie/resources/lib/ui/main.py b/script.easymovie/resources/lib/ui/main.py new file mode 100644 index 000000000..17e76c4ac --- /dev/null +++ b/script.easymovie/resources/lib/ui/main.py @@ -0,0 +1,1096 @@ +""" +EasyMovie UI entry point. + +Orchestrates the full addon flow: +1. Load settings +2. Apply theme +3. Check for in-progress movie (offer resume) +4. Determine mode (Browse/Playlist/Ask) +5. Run filter wizard (if filters need asking) +6. Query movies + apply filters +7. Apply movie set substitutions +8. Show results (browse) or build playlist +9. Handle Re-roll loop + +Logging: + Logger: 'default' + Key events: + - launch.start (INFO): Addon launched + - launch.mode_selected (INFO): Mode determined + - launch.resume_offered (INFO): In-progress movie found + See LOGGING.md for full guidelines. +""" +from __future__ import annotations + +import os +import random +import sys +from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Tuple, cast + +import xbmcvfs + +from resources.lib.constants import ( + ADDON_ID, + MODE_BROWSE, MODE_PLAYLIST, MODE_ASK, + PROP_PLAYLIST_RUNNING, + RESURFACE_WINDOWS, +) +from resources.lib.utils import ( + get_logger, invalidate_icon_cache, json_query, notify, log_timing, lang, +) +from resources.lib.ui.settings import load_settings +from resources.lib.ui.wizard import WizardFlow +from resources.lib.ui.dialogs import show_confirm_dialog, show_select_dialog +from resources.lib.ui.browse_window import ( + show_browse_window, RESULT_REROLL, RESULT_SURPRISE, +) +from resources.lib.data.queries import ( + get_all_movies_query, + get_movie_details_with_art_query, + get_movie_set_details_query, + get_in_progress_movies_query, +) +from resources.lib.data.filters import apply_filters, filter_by_playlist_ids +from resources.lib.data.smart_playlists import extract_movie_ids_from_playlist +from resources.lib.data.movie_sets import apply_set_substitutions +from resources.lib.data.results import select_and_sort_results +from resources.lib.data.storage import StorageManager + +if TYPE_CHECKING: + from resources.lib.ui.settings import ( + FilterSettings, BrowseSettings, PlaylistSettings, + SetSettings, PlaybackSettings, AdvancedSettings, + ) + +_active_monitors = [] # Keep references to prevent GC + + +def _check_clone_version(addon_id: str, addon_path: str) -> bool: + """Check if a clone needs updating. Returns True if OK to proceed. + + For clones, compares clone version against parent version. + If outdated, prompts for mandatory update. + Returns False if the clone should not proceed (update triggered or declined). + """ + import xbmc + import xbmcaddon + import xbmcgui + + log = get_logger('default') + + # Check if we just updated (Kodi cache may still report old version) + window = xbmcgui.Window(10000) + update_flag = f'EasyMovie.UpdateComplete.{addon_id}' + parent_addon = xbmcaddon.Addon(ADDON_ID) + parent_version = parent_addon.getAddonInfo('version') + + update_flag_version = window.getProperty(update_flag) + if update_flag_version: + if update_flag_version == parent_version: + log.info("Clone update flag detected, skipping version check", + event="clone.update_flag_cleared", addon_id=addon_id, + flag_version=update_flag_version) + return True + else: + # Flag is for an older version — another update happened + window.clearProperty(update_flag) + log.info("Clone update flag outdated, checking version", + event="clone.update_flag_stale", addon_id=addon_id, + flag_version=update_flag_version, + parent_version=parent_version) + + clone_addon = xbmcaddon.Addon(addon_id) + clone_version = clone_addon.getAddonInfo('version') + clone_name = clone_addon.getAddonInfo('name') + + if clone_version == parent_version: + return True + + log.warning("Clone out of date", event="clone.outdated", + clone_version=clone_version, parent_version=parent_version, + addon_id=addon_id) + + # Mandatory update prompt + message = (lang(32709) + '\n' + lang(32710) + '\n\n' + lang(32711)) + confirmed = show_confirm_dialog( + clone_name, + message, + yes_label=lang(32712), # "Update" + no_label=lang(32301), # "Cancel" + ) + + if confirmed: + # Use main addon's update_clone.py (latest update logic) + parent_path = parent_addon.getAddonInfo('path') + if not os.path.isdir(parent_path): + log.error("Parent addon path invalid", + event="clone.update_fail", path=parent_path) + return False + update_script = os.path.join(parent_path, 'resources', 'update_clone.py') + if not os.path.isfile(update_script): + log.error("Update script not found", + event="clone.update_fail", path=update_script) + return False + xbmc.executebuiltin( + f'RunScript({update_script},{parent_path},' + f'{addon_path},{addon_id},{clone_name})' + ) + + return False # Don't proceed — either updating or user cancelled + + +def _prepare_movie_pool( + log, + all_movies: List[Dict[str, Any]], + advanced_settings: AdvancedSettings, + storage: StorageManager, + addon_id: str, +) -> Optional[Tuple[List[Dict[str, Any]], set]]: + """Apply playlist pool filter and prepare resurface exclusions. + + Returns: + (movies, exclude_ids) on success, or None if the pool is empty + and the user was notified. + """ + # Apply playlist pool filter (narrow universe before anything else) + if advanced_settings.movie_pool_enabled and advanced_settings.movie_pool_path: + pool_ids = extract_movie_ids_from_playlist(advanced_settings.movie_pool_path) + if pool_ids: + all_movies = filter_by_playlist_ids(all_movies, pool_ids) + log.debug("Playlist pool applied", event="pool.filter", + pool_movie_count=len(pool_ids), remaining=len(all_movies)) + if not all_movies: + show_confirm_dialog( + "No Movies", + "No movies in your library match the selected playlist.", + yes_label="OK", no_label="", addon_id=addon_id) + return None + else: + log.warning("Playlist pool enabled but empty, using full library", + event="pool.fallback") + + # Prepare resurface exclusion (before wizard, so counts are accurate) + exclude_ids: set = set() + if advanced_settings.avoid_resurface: + storage.validate_suggested(all_movies) + resurface_hours = RESURFACE_WINDOWS.get(advanced_settings.resurface_window, 24) + storage.prune_suggested(resurface_hours) + exclude_ids = storage.get_suggested_ids() + if exclude_ids: + log.debug("Resurface exclusion prepared", + event="history.exclude_prepared", + exclude_count=len(exclude_ids), + window_hours=resurface_hours) + else: + storage.clear_suggested() + + return all_movies, exclude_ids + + +def _run_wizard_and_filter( + log, + all_movies: List[Dict[str, Any]], + filter_settings: FilterSettings, + advanced_settings: AdvancedSettings, + storage: StorageManager, + exclude_ids: set, + addon_id: str, +) -> Optional[List[Dict[str, Any]]]: + """Run filter wizard and apply filters. + + Returns: + Filtered movie list, or None if cancelled or no results. + """ + wizard = WizardFlow(_build_wizard_settings(filter_settings)) + if advanced_settings.remember_filters: + wizard.load_last_answers(storage.load_last_filters()) + + filter_config = _run_wizard(log, wizard, all_movies, addon_id, + show_counts=advanced_settings.show_counts, + cumulative_counts=advanced_settings.cumulative_counts, + exclude_ids=exclude_ids) + if filter_config is None: + log.info("Wizard cancelled", event="wizard.cancel") + return None + + # Apply filters (include resurface exclusions) + if exclude_ids: + filter_config.exclude_ids = list(exclude_ids) + filtered = apply_filters(all_movies, filter_config) + if not filtered: + if exclude_ids: + log.info("All movies excluded by filters and resurface window", + event="history.exhausted", + total=len(all_movies), excluded=len(exclude_ids)) + show_confirm_dialog("No Results", + "All matching movies were recently suggested. " + "Try again later or adjust your re-suggestion window.", + yes_label="OK", no_label="", addon_id=addon_id) + else: + log.info("No movies after filtering", event="filter.no_results", + total=len(all_movies)) + show_confirm_dialog("No Results", + "No movies match your filters.\nTry relaxing your criteria.", + yes_label="OK", no_label="", addon_id=addon_id) + return None + + log.debug("Filtered movies", count=len(filtered), total=len(all_movies)) + + # Save wizard answers for next time + if advanced_settings.remember_filters: + storage.save_last_filters(wizard.get_answers()) + + return filtered + + +def main(addon_id: str = ADDON_ID) -> None: + """Entry point for the EasyMovie addon. + + Args: + addon_id: Addon ID (different for clones). + """ + import xbmc + import xbmcaddon + log = get_logger('default') + addon = xbmcaddon.Addon(addon_id) + + # Check clone version before proceeding + if addon_id != ADDON_ID: + addon_path_str = addon.getAddonInfo('path') + if not _check_clone_version(addon_id, addon_path_str): + return + + version = addon.getAddonInfo('version') + kodi_build = xbmc.getInfoLabel('System.BuildVersion') + kodi_version = kodi_build.split()[0] if kodi_build else 'unknown' + log.info("EasyMovie launched", event="launch.start", + addon_id=addon_id, version=version, kodi=kodi_version) + + # 1. Load settings + (primary_function, _theme, filter_settings, browse_settings, + playlist_settings, set_settings, playback_settings, + advanced_settings) = load_settings(addon_id if addon_id != ADDON_ID else None) + + log.debug("Settings", event="launch.settings", + # Mode + mode=primary_function, + # Browse + view_style=browse_settings.view_style, + browse_count=browse_settings.result_count, + browse_sort=browse_settings.sort_by, + browse_sort_dir=browse_settings.sort_dir, + # Playlist + playlist_count=playlist_settings.movie_count, + playlist_sort=playlist_settings.sort_by, + prioritize_in_progress=playlist_settings.prioritize_in_progress, + # Filters + genre_mode=filter_settings.genre_mode, + watched_mode=filter_settings.watched_mode, + mpaa_mode=filter_settings.mpaa_mode, + runtime_mode=filter_settings.runtime_mode, + year_mode=filter_settings.year_mode, + score_mode=filter_settings.score_mode, + # Sets + set_enabled=set_settings.enabled, + continuation=set_settings.continuation_enabled, + continuation_duration=set_settings.continuation_duration, + # Playback + check_in_progress=playback_settings.check_in_progress, + show_info=playback_settings.show_info_when_playing, + # Advanced + pool_enabled=advanced_settings.movie_pool_enabled, + avoid_resurface=advanced_settings.avoid_resurface, + resurface_window=advanced_settings.resurface_window, + remember_filters=advanced_settings.remember_filters, + show_counts=advanced_settings.show_counts, + cumulative_counts=advanced_settings.cumulative_counts) + + # 1b. Get storage for history (needed for resume check and later) + storage = _get_storage(addon_id) + + # 2. Check for in-progress movie + if playback_settings.check_in_progress: + resumed = _check_in_progress(log, advanced_settings, addon_id, storage=storage) + if resumed: + return + + # 4. Determine mode + mode = primary_function + if mode == MODE_ASK: + mode = _ask_mode(log, addon_id) + if mode is None: + return # User cancelled + + log.info("Mode selected", event="launch.mode_selected", + mode="browse" if mode == MODE_BROWSE else "playlist") + + # 5. Show processing notification + if playback_settings.show_processing_notifications: + notify(lang(32350)) + + # 6. Query all movies (bulk, no art) + with log_timing(log, "movie_query"): + result = json_query(get_all_movies_query()) + all_movies = result.get("movies", []) + + if not all_movies: + show_confirm_dialog("No Movies", "Your library has no movies.", + yes_label="OK", no_label="", addon_id=addon_id) + return + + log.debug("Movies loaded", count=len(all_movies)) + + # 6a. Clean up stale started entries (housekeeping) + storage.validate_started(all_movies) + + # 6b–7. Apply pool filter and prepare resurface exclusions + pool_result = _prepare_movie_pool(log, all_movies, advanced_settings, + storage, addon_id) + if pool_result is None: + return + all_movies, exclude_ids = pool_result + + # 8–10. Run wizard, apply filters, save answers + filtered = _run_wizard_and_filter(log, all_movies, filter_settings, + advanced_settings, storage, + exclude_ids, addon_id) + if filtered is None: + return + + # 11. Show processing notification (wizard dismisses the earlier one) + if playback_settings.show_processing_notifications: + notify(lang(32350)) + + # 12. Execute mode + # Set window property so background service skips set-awareness check + import xbmcgui + window = xbmcgui.Window(10000) + window.setProperty(PROP_PLAYLIST_RUNNING, 'true') + try: + if mode == MODE_BROWSE: + _run_browse_mode(log, filtered, browse_settings, set_settings, + playback_settings, advanced_settings, storage, addon_id) + else: + _run_playlist_mode(log, filtered, playlist_settings, set_settings, + playback_settings, advanced_settings, storage, addon_id) + finally: + window.clearProperty(PROP_PLAYLIST_RUNNING) + + +def _check_in_progress( + log, advanced_settings: AdvancedSettings, addon_id: str, + storage: Optional['StorageManager'] = None, +) -> bool: + """Check for in-progress movies started by EasyMovie and offer to resume.""" + from resources.lib.playback.player import play_movie, get_resume_info + result = json_query(get_in_progress_movies_query()) + movies = result.get("movies", []) + if not movies: + return False + + # Only consider movies that EasyMovie started + started_ids = storage.get_started_ids() if storage else set() + if started_ids: + movies = [m for m in movies if m.get("movieid", 0) in started_ids] + if not movies: + log.debug("In-progress movies found but none started by EasyMovie", + event="launch.resume_skip_foreign") + return False + else: + # No tracking data yet — skip resume check entirely + # (first run, or storage was cleared) + log.debug("No EasyMovie-started movies tracked, skipping resume check", + event="launch.resume_skip_no_history") + return False + + movie = movies[0] + resume = get_resume_info(movie) + if not resume: + return False + + title = movie.get("title", "Unknown") + remaining = resume["remaining_minutes"] + + log.info("In-progress movie found", event="launch.resume_offered", + title=title, remaining_minutes=remaining) + + confirmed = show_confirm_dialog( + "Resume Movie?", + f"{title}\n{remaining} minutes remaining", + yes_label="Resume", + no_label="New Selection", + addon_id=addon_id, + ) + + if confirmed: + play_movie(movie, resume=True, storage=storage) + return True + return False + + +def _ask_mode(log, addon_id: str = ADDON_ID) -> Optional[int]: + """Ask the user to choose Browse or Playlist mode.""" + import xbmcaddon + addon_name = xbmcaddon.Addon(addon_id).getAddonInfo('name') + result = show_confirm_dialog( + heading=addon_name, + message=lang(32320), # "Choose Mode" + yes_label=lang(32321), # "Browse" + no_label=lang(32322), # "Playlist" + addon_id=addon_id, + ) + if result is None: + return None # User pressed back/escape + return MODE_BROWSE if result else MODE_PLAYLIST + + +def _build_wizard_settings(filter_settings: FilterSettings) -> Dict[str, Any]: + """Convert FilterSettings to the dict format WizardFlow expects.""" + return { + "ignore_genre_mode": filter_settings.ignore_genre_mode, + "ignore_genre_match_and": filter_settings.ignore_genre_match_and, + "preset_ignore_genres": filter_settings.preset_ignore_genres, + "genre_mode": filter_settings.genre_mode, + "genre_match_and": filter_settings.genre_match_and, + "preset_genres": filter_settings.preset_genres, + "watched_mode": filter_settings.watched_mode, + "watched_preset": filter_settings.watched_preset, + "mpaa_mode": filter_settings.mpaa_mode, + "preset_mpaa": filter_settings.preset_mpaa, + "runtime_mode": filter_settings.runtime_mode, + "runtime_min": filter_settings.runtime_min, + "runtime_max": filter_settings.runtime_max, + "year_mode": filter_settings.year_mode, + "year_filter_type": filter_settings.year_filter_type, + "year_from": filter_settings.year_from, + "year_to": filter_settings.year_to, + "year_recency": filter_settings.year_recency, + "score_mode": filter_settings.score_mode, + "min_score": filter_settings.min_score, + } + + +def _run_multi_select_step( + items: List[str], + pool: list, + value_fn: Callable[[Dict[str, Any]], List[str]], + dialog_title: str, + preselected: List[str], + addon_id: str, + show_counts: bool, + fmt_fn: Callable[[str, int], str], +) -> Optional[List[str]]: + """Run a multi-select filter step with optional counts. + + Args: + items: The unique values to display (e.g. genre names). + pool: Movie pool for counting. + value_fn: Extracts matching values from a movie dict + (e.g. ``lambda m: m.get("genre", [])``). + dialog_title: Heading for the select dialog. + preselected: Previously selected values (for back-navigation). + addon_id: Addon ID for theming. + show_counts: Whether to append counts to labels. + fmt_fn: Formats a label with its count. + + Returns: + List of selected values, or None if cancelled. + """ + if show_counts: + counts: Dict[str, int] = {} + for m in pool: + for v in value_fn(m): + counts[v] = counts.get(v, 0) + 1 + labels = [fmt_fn(item, counts.get(item, 0)) for item in items] + else: + labels = list(items) + pre_indices = [i for i, item in enumerate(items) if item in preselected] + result = show_select_dialog(dialog_title, labels, + multi_select=True, preselected=pre_indices, + addon_id=addon_id) + if result is None: + return None + return [items[i] for i in result] + + +def _run_range_select_step( + ranges: list, + pool: list, + match_fn: Callable[[Dict[str, Any], tuple], bool], + label_fn: Callable[[tuple], str], + dialog_title: str, + addon_id: str, + show_counts: bool, + fmt_fn: Callable[[str, int], str], +) -> Optional[List[int]]: + """Run a single-select range filter step with optional counts. + + Args: + ranges: List of range tuples from constants. + pool: Movie pool for counting. + match_fn: Tests whether a movie matches a given range tuple. + label_fn: Extracts the display label from a range tuple. + dialog_title: Heading for the select dialog. + addon_id: Addon ID for theming. + show_counts: Whether to append counts to labels. + fmt_fn: Formats a label with its count. + + Returns: + Dialog result (list of selected indices), or None if cancelled. + """ + items: List[str] = [] + for r in ranges: + label = label_fn(r) + if show_counts: + count = sum(1 for m in pool if match_fn(m, r)) + items.append(fmt_fn(label, count)) + else: + items.append(label) + return show_select_dialog(dialog_title, items, multi_select=False, + addon_id=addon_id) + + +def _run_wizard(log, wizard: WizardFlow, all_movies: list, + addon_id: str = ADDON_ID, + show_counts: bool = True, + cumulative_counts: bool = False, + exclude_ids: Optional[set] = None) -> Optional[Any]: + """Run the wizard flow, returning a FilterConfig or None if cancelled.""" + from resources.lib.data.filters import ( + extract_unique_genres, extract_unique_mpaa, + ) + from resources.lib.constants import RUNTIME_RANGES, SCORE_RANGES + + from resources.lib.data.filters import apply_filters as _apply_filters + + def _count_pool() -> list: + """Get the movie pool for counting — full or cumulative.""" + if not show_counts: + return [] + if not cumulative_counts: + if exclude_ids: + return [m for m in all_movies + if m.get("movieid", 0) not in exclude_ids] + return all_movies + # Build partial filter config from completed steps only + partial_config = wizard.build_partial_filter_config() + if exclude_ids: + partial_config.exclude_ids = list(exclude_ids) + return _apply_filters(all_movies, partial_config, reason="cumulative_count") + + def _fmt(label: str, count: int) -> str: + """Format a label with optional count.""" + if show_counts: + return f"{label} ({count})" + return label + + if wizard.is_complete: + return wizard.build_filter_config() + + while not wizard.is_complete: + step = wizard.current_step + if step is None: + break + + filter_type = step.filter_type + answer = None + + if filter_type == "ignore_genre": + genres = extract_unique_genres(all_movies) + answer = _run_multi_select_step( + genres, _count_pool(), + lambda m: m.get("genre", []), + lang(32204), + wizard.get_answers().get("ignore_genre", []), + addon_id, show_counts, _fmt, + ) + if answer is None: + if not wizard.go_back(): + return None + continue + + elif filter_type == "genre": + genres = extract_unique_genres(all_movies) + answer = _run_multi_select_step( + genres, _count_pool(), + lambda m: m.get("genre", []), + "Select Genres", + wizard.get_answers().get("genre", []), + addon_id, show_counts, _fmt, + ) + if answer is None: + if not wizard.go_back(): + return None + continue + + elif filter_type == "watched": + pool = _count_pool() + if show_counts: + unwatched = sum(1 for m in pool if m.get("playcount", 0) == 0) + watched = len(pool) - unwatched + items = [ + _fmt("Unwatched only", unwatched), + _fmt("Watched only", watched), + _fmt("Both", len(pool)), + ] + else: + items = ["Unwatched only", "Watched only", "Both"] + result = show_select_dialog("Watched Status", items, multi_select=False, + addon_id=addon_id) + if result is None: + if not wizard.go_back(): + return None + continue + answer = result[0] # 0=unwatched, 1=watched, 2=both + + elif filter_type == "mpaa": + ratings = extract_unique_mpaa(all_movies) + answer = _run_multi_select_step( + ratings, _count_pool(), + lambda m: [m.get("mpaa", "")] if m.get("mpaa", "") else [], + "Select Age Ratings", + wizard.get_answers().get("mpaa", []), + addon_id, show_counts, _fmt, + ) + if answer is None: + if not wizard.go_back(): + return None + continue + + elif filter_type == "runtime": + result = _run_range_select_step( + RUNTIME_RANGES, _count_pool(), + lambda m, r: ((r[0] == 0 or m.get("runtime", 0) >= r[0] * 60) + and (r[1] == 0 or m.get("runtime", 0) <= r[1] * 60)), + lambda r: r[2], + "Select Runtime", addon_id, show_counts, _fmt, + ) + if result is None: + if not wizard.go_back(): + return None + continue + idx = result[0] + rt_min, rt_max, _ = RUNTIME_RANGES[idx] + answer = {"min": rt_min, "max": rt_max} + + elif filter_type == "year": + # Combined recency + decade picker + from resources.lib.data.filters import extract_decade_buckets + from resources.lib.constants import RECENCY_RANGES + import datetime + + current_year = datetime.datetime.now().year + pool = _count_pool() + buckets = extract_decade_buckets(pool if show_counts else all_movies) + + # Build items: recency, then header + decades, then "Any year" + items = [] + header_indices = set() + + for years_ago, label_id in RECENCY_RANGES: + if show_counts: + cutoff_year = current_year - years_ago + rcount = sum(1 for m in pool if m.get("year", 0) >= cutoff_year) + items.append(_fmt(lang(label_id), rcount)) + else: + items.append(lang(label_id)) + + # "— By decade —" group header (only if there are decade buckets) + if buckets: + header_indices.add(len(items)) + items.append(lang(32206)) + + for _, count, label in buckets: + items.append(_fmt(label, count) if show_counts else label) + items.append(_fmt(lang(32220), len(pool)) if show_counts + else lang(32220)) + + result = show_select_dialog(lang(32202), items, multi_select=False, + addon_id=addon_id, + headers=header_indices) + if result is None: + if not wizard.go_back(): + return None + continue + if not result: + answer = {"from": 0, "to": 0} # No filter, same as "Any year" + wizard.set_answer(filter_type, answer) + log.debug("Wizard answer", event="wizard.answer", + filter_type=filter_type, answer=answer) + if not wizard.advance(): + break + continue + + # Map selected index back to data index (skip headers) + idx = result[0] + headers_before = sum(1 for h in header_indices if h < idx) + data_idx = idx - headers_before + + recency_count = len(RECENCY_RANGES) + if data_idx < recency_count: + # Recency selection + years_ago = RECENCY_RANGES[data_idx][0] + answer = {"from": current_year - years_ago, "to": 0} + elif data_idx < recency_count + len(buckets): + # Decade selection + bucket_idx = data_idx - recency_count + decade_start, _, _ = buckets[bucket_idx] + answer = {"from": decade_start, "to": decade_start + 9} + else: + answer = {"from": 0, "to": 0} + + elif filter_type == "score": + result = _run_range_select_step( + SCORE_RANGES, _count_pool(), + lambda m, r: m.get("rating", 0.0) * 10 >= r[0], + lambda r: r[1], + "Select Score", addon_id, show_counts, _fmt, + ) + if result is None: + if not wizard.go_back(): + return None + continue + if not result: + answer = 0 # No filter, same as "Any score" + wizard.set_answer(filter_type, answer) + log.debug("Wizard answer", event="wizard.answer", + filter_type=filter_type, answer=answer) + if not wizard.advance(): + break + continue + idx = result[0] + answer = SCORE_RANGES[idx][0] + + wizard.set_answer(filter_type, answer) + log.debug("Wizard answer", event="wizard.answer", + filter_type=filter_type, answer=answer) + if not wizard.advance(): + break # Wizard complete + + config = wizard.build_filter_config() + log.debug("Wizard complete", event="wizard.complete") + return config + + +def _get_storage(addon_id: str) -> StorageManager: + """Get the storage manager for the addon.""" + storage_dir = xbmcvfs.translatePath( + f"special://profile/addon_data/{addon_id}/" + ) + import os + os.makedirs(storage_dir, exist_ok=True) + return StorageManager(os.path.join(storage_dir, "easymovie_data.json")) + + +def _load_set_details( + movies: List[Dict[str, Any]] +) -> Dict[int, Dict[str, Any]]: + """Load movie set details for all set-member movies.""" + _log = get_logger('data') + set_ids = {m.get("setid", 0) for m in movies if m.get("setid", 0)} + set_cache: Dict[int, Dict[str, Any]] = {} + with log_timing(_log, "load_set_details", set_count=len(set_ids)): + for set_id in set_ids: + result = json_query(get_movie_set_details_query(set_id)) + if result: + # Unwrap: json_query returns {"setdetails": {...}} + set_cache[set_id] = result.get("setdetails", result) + # Remove sets with only 1 movie in library (not useful for set features) + set_cache = { + sid: details for sid, details in set_cache.items() + if len(details.get("movies", [])) >= 2 + } + return set_cache + + +def _load_art_for_movies( + movies: List[Dict[str, Any]] +) -> List[Dict[str, Any]]: + """Load art and plot for a list of movies via individual detail queries.""" + if not movies: + return movies + _log = get_logger('data') + enriched: List[Dict[str, Any]] = [] + with log_timing(_log, "load_art_for_movies", movie_count=len(movies)): + for movie in movies: + movie_id = movie.get("movieid", 0) + if not movie_id: + enriched.append(movie) + continue + result = json_query(get_movie_details_with_art_query(movie_id)) + details = result.get("moviedetails") + if details: + enriched.append(details) + else: + enriched.append(movie) + return enriched + + +def _run_browse_mode( + log, + filtered: List[Dict[str, Any]], + browse_settings: BrowseSettings, + set_settings: SetSettings, + playback_settings: PlaybackSettings, + advanced_settings: AdvancedSettings, + storage: StorageManager, + addon_id: str, +) -> None: + """Run the browse mode loop with Re-roll support.""" + from resources.lib.playback.player import play_movie + from resources.lib.playback.playlist_builder import build_and_play_playlist + while True: + # Exclude previously suggested from this session's pool + if advanced_settings.avoid_resurface: + suggested_ids = storage.get_suggested_ids() + available = [m for m in filtered if m.get("movieid", 0) not in suggested_ids] + if not available: + log.info("All filtered movies exhausted, resetting pool", + event="ui.pool_reset", total=len(filtered)) + available = filtered + else: + available = filtered + + # Select and sort + results = select_and_sort_results( + available, browse_settings.result_count, + browse_settings.sort_by, browse_settings.sort_dir, + ) + + # Apply movie set substitutions + if set_settings.enabled: + set_cache = _load_set_details(results) + results = apply_set_substitutions(results, set_cache) + + # Load art for display (re-fetches full details including set/setid) + results = _load_art_for_movies(results) + + # Strip set info from single-movie sets after art loading + if set_settings.enabled: + valid_set_ids = set(set_cache.keys()) + for movie in results: + if movie.get("setid", 0) and movie["setid"] not in valid_set_ids: + movie["set"] = "" + movie["setid"] = 0 + + # Record as suggested (only when resurface avoidance is on) + if advanced_settings.avoid_resurface: + for movie in results: + storage.add_suggested(movie.get("movieid", 0), movie.get("title", "")) + + # Show browse window + titles = [m.get("title", "") for m in results] + log.debug("Presenting movies", event="browse.present", + count=len(results), pool=len(available), titles=titles) + result = show_browse_window(results, browse_settings.view_style, addon_id) + + if result == RESULT_REROLL: + log.info("Re-rolling", event="ui.reroll") + if playback_settings.show_processing_notifications: + notify(lang(32350)) + continue + elif result == RESULT_SURPRISE: + if not results: + continue + movie = random.choice(results) + log.info("Surprise Me", event="ui.surprise", + title=movie.get("title", "")) + play_movie(movie, storage=storage) + break + elif isinstance(result, dict) and result.get("__play_set__"): + # Play Full Set from context menu + movie = result["movie"] + set_id = movie.get("setid", 0) + if set_id: + raw = json_query(get_movie_set_details_query(set_id)) + set_details = raw.get("setdetails", raw) if raw else {} + set_movies = set_details.get("movies", []) + if set_movies: + log.info("Playing full set", event="playlist.play_set", + set_name=movie.get("set", ""), + movie_count=len(set_movies)) + build_and_play_playlist(set_movies, storage=storage) + break + elif result is not None: + log.info("Playing movie", event="playback.start", + title=result.get("title", ""), + movieid=result.get("movieid", 0)) + play_movie(result, storage=storage) + break + else: + break # User closed + + +def _run_playlist_mode( + log, + filtered: List[Dict[str, Any]], + playlist_settings: PlaylistSettings, + set_settings: SetSettings, + playback_settings: PlaybackSettings, + advanced_settings: AdvancedSettings, + storage: StorageManager, + addon_id: str, +) -> None: + """Run playlist mode.""" + from resources.lib.playback.playlist_builder import build_and_play_playlist + from resources.lib.playback.playback_monitor import PlaybackMonitor + # Select and sort + results = select_and_sort_results( + filtered, playlist_settings.movie_count, + playlist_settings.sort_by, playlist_settings.sort_dir, + ) + + # Apply movie set substitutions + if set_settings.enabled: + set_cache = _load_set_details(results) + results = apply_set_substitutions(results, set_cache) + # Strip set info from movies whose sets were filtered out + valid_set_ids = set(set_cache.keys()) + for movie in results: + if movie.get("setid", 0) and movie["setid"] not in valid_set_ids: + movie["set"] = "" + movie["setid"] = 0 + + # Record as suggested (only when resurface avoidance is on) + if advanced_settings.avoid_resurface: + for movie in results: + storage.add_suggested(movie.get("movieid", 0), movie.get("title", "")) + + # Build and play playlist + success = build_and_play_playlist( + results, + show_notifications=playback_settings.show_processing_notifications, + prioritize_in_progress=playlist_settings.prioritize_in_progress, + resume_from_position=playlist_settings.resume_from_position, + storage=storage, + ) + + if not success: + return + + # Start playback monitor for set continuation + if set_settings.enabled and set_settings.continuation_enabled: + set_cache = _load_set_details(results) + movies_by_id = {m.get("movieid", 0): m for m in results} + # Monitor runs as part of xbmc.Player — Kodi calls its callbacks. + # Must keep reference to prevent GC during playback session. + monitor = PlaybackMonitor( + set_cache=set_cache, + movies=movies_by_id, + continuation_duration=set_settings.continuation_duration, + continuation_default=set_settings.continuation_default, + addon_id=addon_id, + ) + _active_monitors.append(monitor) + + +def _reopen_settings(addon_id: str) -> None: + """Force-close dialogs and reopen settings to show updated values. + + Kodi's settings dialog caches values in memory. After a selector + changes settings via setSetting(), we must close and reopen to + pick up the new values. + """ + import xbmc + xbmc.executebuiltin('Dialog.Close(all,true)') + xbmc.executebuiltin( + f'AlarmClock(EasyMovieSettings,Addon.OpenSettings({addon_id}),00:01,silent)' + ) + + +def _handle_entry_args(addon_id: str) -> bool: + """Handle command-line arguments for special entry points. + + Returns True if the args were handled (caller should exit). + """ + if len(sys.argv) < 2: + return False + + action = sys.argv[1] + + if action == 'selector': + from resources.selector import main as selector_main + selector_main() + _reopen_settings(addon_id) + return True + elif action == 'clone': + from resources.clone import create_clone + create_clone() + return True + elif action == 'dialog_preview': + from resources import dialog_preview + override = sys.argv[2] if len(sys.argv) > 2 else None + dialog_preview.Main(override) + return True + elif action == 'set_icon': + from resources.lib.utils import get_addon + import xbmcvfs as _xbmcvfs + import xbmcgui + log = get_logger('default') + addon = get_addon(addon_id) + addon_path = addon.getAddonInfo('path') + icons_dir = os.path.join(addon_path, 'resources', 'icons') + icon_names = ["Golden Hour", "Ultraviolet", "Ember", "Nightfall", "Browse..."] + icon_files = [ + "icon-golden-hour.png", "icon-ultraviolet.png", + "icon-ember.png", "icon-nightfall.png", + ] + from resources.lib.constants import CUSTOM_ICON_BACKUP + addon_data = _xbmcvfs.translatePath( + f'special://profile/addon_data/{addon_id}/' + ) + backup_path = os.path.join(addon_data, CUSTOM_ICON_BACKUP) + result = show_select_dialog( + heading="Choose Icon", + items=icon_names, + multi_select=False, + addon_id=addon_id, + ) + if result is not None: + idx = result[0] + dst = os.path.join(addon_path, 'icon.png') + if idx < len(icon_files): + src = os.path.join(icons_dir, icon_files[idx]) + ok = _xbmcvfs.copy(src, dst) + if ok: + addon.setSetting('icon_choice', + f'built-in:{icon_files[idx]}') + _xbmcvfs.copy(src, backup_path) + log.info("Icon set" if ok else "Icon set failed", + event="icon.set", source=src, target=dst, success=ok) + else: + dialog = xbmcgui.Dialog() + image = dialog.browse(2, "Select Icon", 'files', '.png|.jpg|.jpeg') + if image: + ok = _xbmcvfs.copy(cast(str, image), dst) + if ok: + addon.setSetting('icon_choice', 'custom') + _xbmcvfs.copy(cast(str, image), backup_path) + log.info("Custom icon set" if ok else "Custom icon set failed", + event="icon.set", source=cast(str, image), + target=dst, success=ok) + invalidate_icon_cache(addon_id) + _reopen_settings(addon_id) + return True + elif action == 'reset_icon': + from resources.lib.utils import get_addon + from resources.lib.constants import CUSTOM_ICON_BACKUP + import xbmcvfs as _xbmcvfs + addon = get_addon(addon_id) + addon_path = addon.getAddonInfo('path') + default_icon = os.path.join(addon_path, 'icon_default.png') + icon_path = os.path.join(addon_path, 'icon.png') + if _xbmcvfs.exists(default_icon): + _xbmcvfs.copy(default_icon, icon_path) + addon.setSetting('icon_choice', '') + addon_data = _xbmcvfs.translatePath( + f'special://profile/addon_data/{addon_id}/' + ) + backup_path = os.path.join(addon_data, CUSTOM_ICON_BACKUP) + if _xbmcvfs.exists(backup_path): + _xbmcvfs.delete(backup_path) + invalidate_icon_cache(addon_id) + _reopen_settings(addon_id) + return True + + return False diff --git a/script.easymovie/resources/lib/ui/settings.py b/script.easymovie/resources/lib/ui/settings.py new file mode 100644 index 000000000..c4e96b22b --- /dev/null +++ b/script.easymovie/resources/lib/ui/settings.py @@ -0,0 +1,212 @@ +""" +Settings loader for EasyMovie. + +Reads all addon settings and produces typed configuration +objects used by the rest of the addon. + +Logging: + Logger: 'ui' + Key events: None (settings are logged by ui/main.py at launch.settings) + See LOGGING.md for full guidelines. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from resources.lib.utils import get_bool_setting, get_int_setting, get_string_setting, get_logger +from resources.lib.constants import ( + FILTER_ASK, FILTER_SKIP, + WATCHED_UNWATCHED, + YEAR_FILTER_AFTER, + VIEW_SHOWCASE, + SORT_RANDOM, SORT_DESC, + CONTINUATION_DEFAULT_CONTINUE_SET, + MODE_ASK, + THEME_GOLDEN_HOUR, +) + +log = get_logger('ui') + + +@dataclass +class FilterSettings: + """Pre-configured filter values from settings.""" + ignore_genre_mode: int = FILTER_SKIP + ignore_genre_match_and: bool = False + preset_ignore_genres: Optional[List[str]] = None + genre_mode: int = FILTER_ASK + genre_match_and: bool = False + preset_genres: Optional[List[str]] = None + watched_mode: int = FILTER_ASK + watched_preset: int = WATCHED_UNWATCHED + mpaa_mode: int = FILTER_SKIP + preset_mpaa: Optional[List[str]] = None + runtime_mode: int = FILTER_SKIP + runtime_min: int = 0 + runtime_max: int = 0 + year_mode: int = FILTER_SKIP + year_filter_type: int = YEAR_FILTER_AFTER + year_from: int = 0 + year_to: int = 0 + year_recency: int = 5 + score_mode: int = FILTER_SKIP + min_score: int = 0 + + +@dataclass +class BrowseSettings: + """Browse mode configuration.""" + view_style: int = VIEW_SHOWCASE + return_to_list: bool = True + result_count: int = 10 + sort_by: int = SORT_RANDOM + sort_dir: int = SORT_DESC + + +@dataclass +class PlaylistSettings: + """Playlist mode configuration.""" + movie_count: int = 5 + sort_by: int = SORT_RANDOM + sort_dir: int = SORT_DESC + prioritize_in_progress: bool = True + resume_from_position: bool = True + + +@dataclass +class SetSettings: + """Movie set configuration.""" + enabled: bool = True + show_set_info: bool = True + continuation_enabled: bool = True + continuation_duration: int = 20 + continuation_default: int = CONTINUATION_DEFAULT_CONTINUE_SET + + +@dataclass +class PlaybackSettings: + """Playback configuration.""" + check_in_progress: bool = True + show_info_when_playing: bool = True + show_processing_notifications: bool = True + + +@dataclass +class AdvancedSettings: + """Advanced configuration.""" + movie_pool_enabled: bool = False + movie_pool_path: str = "" + avoid_resurface: bool = True + resurface_window: int = 3 # Index into RESURFACE_WINDOWS (24h) + remember_filters: bool = True + show_counts: bool = True + cumulative_counts: bool = False + debug_logging: bool = False + + +def _parse_json_list(value: str) -> Optional[List[str]]: + """Parse a JSON string into a list, returning None on failure.""" + if not value: + return None + try: + result = json.loads(value) + if isinstance(result, list): + return result + except (json.JSONDecodeError, TypeError): + pass + return None + + +def load_settings( + addon_id: Optional[str] = None, +) -> Tuple[int, int, FilterSettings, BrowseSettings, PlaylistSettings, + SetSettings, PlaybackSettings, AdvancedSettings]: + """Load all settings. + + Args: + addon_id: Optional addon ID for clone support. + + Returns: + Tuple of (primary_function, theme, FilterSettings, + BrowseSettings, PlaylistSettings, SetSettings, + PlaybackSettings, AdvancedSettings) + """ + # Main + primary_function = get_int_setting('primary_function', addon_id, default=MODE_ASK) + theme = get_int_setting('theme', addon_id, default=THEME_GOLDEN_HOUR) + + # Filters + filter_settings = FilterSettings( + ignore_genre_mode=get_int_setting('ignore_genre_mode', addon_id, default=FILTER_SKIP), + ignore_genre_match_and=get_int_setting('ignore_genre_match', addon_id, default=0) == 1, + preset_ignore_genres=_parse_json_list(get_string_setting('selected_ignore_genres', addon_id)), + genre_mode=get_int_setting('genre_mode', addon_id, default=FILTER_ASK), + genre_match_and=get_int_setting('genre_match', addon_id, default=0) == 1, + preset_genres=_parse_json_list(get_string_setting('selected_genres', addon_id)), + watched_mode=get_int_setting('watched_mode', addon_id, default=FILTER_ASK), + watched_preset=get_int_setting('watched_preset', addon_id, default=WATCHED_UNWATCHED), + mpaa_mode=get_int_setting('mpaa_mode', addon_id, default=FILTER_SKIP), + preset_mpaa=_parse_json_list(get_string_setting('selected_mpaa', addon_id)), + runtime_mode=get_int_setting('runtime_mode', addon_id, default=FILTER_SKIP), + runtime_min=get_int_setting('runtime_min', addon_id, default=0), + runtime_max=get_int_setting('runtime_max', addon_id, default=0), + year_mode=get_int_setting('year_mode', addon_id, default=FILTER_SKIP), + year_filter_type=get_int_setting('year_filter_type', addon_id, default=YEAR_FILTER_AFTER), + year_from=get_int_setting('year_from', addon_id, default=0), + year_to=get_int_setting('year_to', addon_id, default=0), + year_recency=get_int_setting('year_recency', addon_id, default=5), + score_mode=get_int_setting('score_mode', addon_id, default=FILTER_SKIP), + min_score=get_int_setting('min_score', addon_id, default=0), + ) + + # Browse + browse_settings = BrowseSettings( + view_style=get_int_setting('view_style', addon_id, default=VIEW_SHOWCASE), + return_to_list=get_bool_setting('return_to_list', addon_id), + result_count=get_int_setting('browse_count', addon_id, default=10), + sort_by=get_int_setting('browse_sort', addon_id, default=SORT_RANDOM), + sort_dir=get_int_setting('browse_sort_dir', addon_id, default=SORT_DESC), + ) + + # Playlist + playlist_settings = PlaylistSettings( + movie_count=get_int_setting('playlist_count', addon_id, default=5), + sort_by=get_int_setting('playlist_sort', addon_id, default=SORT_RANDOM), + sort_dir=get_int_setting('playlist_sort_dir', addon_id, default=SORT_DESC), + prioritize_in_progress=get_bool_setting('prioritize_in_progress', addon_id), + resume_from_position=get_bool_setting('resume_from_position', addon_id), + ) + + # Movie Sets + set_settings = SetSettings( + enabled=get_bool_setting('set_enabled', addon_id), + show_set_info=get_bool_setting('set_show_info', addon_id), + continuation_enabled=get_bool_setting('continuation_enabled', addon_id), + continuation_duration=get_int_setting('continuation_duration', addon_id, default=20), + continuation_default=get_int_setting('continuation_default', addon_id, + default=CONTINUATION_DEFAULT_CONTINUE_SET), + ) + + # Playback + playback_settings = PlaybackSettings( + check_in_progress=get_bool_setting('check_in_progress', addon_id), + show_info_when_playing=get_bool_setting('show_info_playing', addon_id), + show_processing_notifications=get_bool_setting('show_notifications', addon_id), + ) + + # Advanced + advanced_settings = AdvancedSettings( + movie_pool_enabled=get_bool_setting('pool_enabled', addon_id), + movie_pool_path=get_string_setting('movie_pool_playlist_path', addon_id), + avoid_resurface=get_bool_setting('avoid_resurface', addon_id), + resurface_window=get_int_setting('resurface_window', addon_id, default=3), + remember_filters=get_bool_setting('remember_filters', addon_id), + show_counts=get_bool_setting('show_counts', addon_id), + cumulative_counts=get_bool_setting('cumulative_counts', addon_id), + debug_logging=get_bool_setting('logging', addon_id), + ) + + return (primary_function, theme, filter_settings, browse_settings, + playlist_settings, set_settings, playback_settings, advanced_settings) diff --git a/script.easymovie/resources/lib/ui/wizard.py b/script.easymovie/resources/lib/ui/wizard.py new file mode 100644 index 000000000..454d90391 --- /dev/null +++ b/script.easymovie/resources/lib/ui/wizard.py @@ -0,0 +1,258 @@ +""" +Filter wizard flow controller. + +Manages the sequence of filter dialogs, back navigation, +answer persistence, and building the final FilterConfig. + +Logging: + Logger: 'wizard' + Key events: + - filter.ask (DEBUG): Filter step presented to user + - filter.preset (DEBUG): Filter using preset value + - filter.skip (DEBUG): Filter step skipped + See LOGGING.md for full guidelines. +""" +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from resources.lib.constants import ( + FILTER_ASK, FILTER_PRESET, FILTER_SKIP, + WATCHED_BOTH, + YEAR_FILTER_RECENCY, +) +from resources.lib.data.filters import FilterConfig +from resources.lib.utils import get_logger + +log = get_logger('wizard') + + +# The ordered list of filter types in the wizard +FILTER_ORDER = ["ignore_genre", "genre", "watched", "mpaa", "runtime", "year", "score"] + +# Mapping from filter type to settings mode key +_MODE_KEYS = { + "ignore_genre": "ignore_genre_mode", + "genre": "genre_mode", + "watched": "watched_mode", + "mpaa": "mpaa_mode", + "runtime": "runtime_mode", + "year": "year_mode", + "score": "score_mode", +} + + +@dataclass +class WizardStep: + """A single step in the wizard flow.""" + filter_type: str + index: int + + +def _get_preset_value(settings: Dict[str, Any], filter_type: str) -> Any: + """Get the preset value for a filter type, for logging.""" + key_map = { + "ignore_genre": "preset_ignore_genres", + "genre": "preset_genres", + "watched": "watched_preset", + "mpaa": "preset_mpaa", + "runtime": ("runtime_min", "runtime_max"), + "year": ("year_from", "year_to"), + "score": "min_score", + } + key = key_map.get(filter_type) + if isinstance(key, tuple): + return {k: settings.get(k) for k in key} + if key: + return settings.get(key) + return None + + +class WizardFlow: + """Manages the wizard flow for filter selection. + + Reads filter mode settings to determine which steps to show, + maintains answer stack for back navigation, and builds a + FilterConfig from combined preset + user answers. + """ + + def __init__(self, settings: Dict[str, Any]) -> None: + """Initialize wizard from settings. + + Args: + settings: Dict containing filter mode settings + (genre_mode, watched_mode, etc.) and preset values. + """ + self._settings = settings + self._answers: Dict[str, Any] = {} + self._current_index = 0 + + # Build step list: only filters set to ASK + self.steps: List[WizardStep] = [] + for i, filter_type in enumerate(FILTER_ORDER): + mode_key = _MODE_KEYS[filter_type] + mode = settings.get(mode_key, FILTER_SKIP) + if mode == FILTER_ASK: + log.debug("Filter step will be presented", + event="filter.ask", filter_type=filter_type) + self.steps.append(WizardStep( + filter_type=filter_type, + index=len(self.steps), + )) + elif mode == FILTER_PRESET: + preset_value = _get_preset_value(settings, filter_type) + log.debug("Filter using preset value", + event="filter.preset", filter_type=filter_type, + value=preset_value) + else: + log.debug("Filter step skipped", + event="filter.skip", filter_type=filter_type) + + @property + def current_step_index(self) -> int: + """Current position in the wizard.""" + return self._current_index + + @property + def current_step(self) -> Optional[WizardStep]: + """Get the current step, or None if complete.""" + if self._current_index < len(self.steps): + return self.steps[self._current_index] + return None + + @property + def is_complete(self) -> bool: + """Whether the wizard has no more steps.""" + return self._current_index >= len(self.steps) + + def advance(self) -> bool: + """Move to the next step. + + Returns: + True if there is a next step, False if wizard is now complete. + """ + self._current_index += 1 + return self._current_index < len(self.steps) + + def go_back(self) -> bool: + """Move to the previous step. + + Returns: + True if moved back, False if already at start (signals cancel). + """ + if self._current_index <= 0: + return False + self._current_index -= 1 + return True + + def set_answer(self, filter_type: str, value: Any) -> None: + """Record the user's answer for a filter step.""" + self._answers[filter_type] = value + + def get_answers(self) -> Dict[str, Any]: + """Get all recorded answers.""" + return dict(self._answers) + + def load_last_answers(self, answers: Dict[str, Any]) -> None: + """Pre-populate answers from a previous session.""" + self._answers.update(answers) + + def build_partial_filter_config(self) -> FilterConfig: + """Build a FilterConfig using only answers for steps before the current one. + + Used for cumulative counting: when showing step N, counts should + reflect filters from steps 0..N-1 only, not pre-loaded future answers. + """ + # Determine which filter types have been answered in this session + completed_types = set() + for i, step in enumerate(self.steps): + if i >= self._current_index: + break + completed_types.add(step.filter_type) + + # Temporarily mask answers for uncompleted steps + saved_answers = dict(self._answers) + for key in list(self._answers.keys()): + if key not in completed_types: + del self._answers[key] + + config = self.build_filter_config() + + # Restore all answers + self._answers = saved_answers + return config + + def build_filter_config(self) -> FilterConfig: + """Build a FilterConfig from combined preset values and wizard answers. + + For each filter type: + - ASK: use the wizard answer + - PRESET: use the preset value from settings + - SKIP: use default (no filter) + """ + config = FilterConfig() + + # Ignore genres + ignore_genre_mode = self._settings.get("ignore_genre_mode", FILTER_SKIP) + if ignore_genre_mode == FILTER_ASK: + config.ignore_genres = self._answers.get("ignore_genre") + elif ignore_genre_mode == FILTER_PRESET: + config.ignore_genres = self._settings.get("preset_ignore_genres") + config.ignore_genre_match_and = self._settings.get( + "ignore_genre_match_and", False) + + # Genre + genre_mode = self._settings.get("genre_mode", FILTER_SKIP) + if genre_mode == FILTER_ASK: + config.genres = self._answers.get("genre") + elif genre_mode == FILTER_PRESET: + config.genres = self._settings.get("preset_genres") + config.genre_match_and = self._settings.get("genre_match_and", False) + + # Watched + watched_mode = self._settings.get("watched_mode", FILTER_SKIP) + if watched_mode == FILTER_ASK: + config.watched = self._answers.get("watched", WATCHED_BOTH) + elif watched_mode == FILTER_PRESET: + config.watched = self._settings.get("watched_preset", WATCHED_BOTH) + + # MPAA + mpaa_mode = self._settings.get("mpaa_mode", FILTER_SKIP) + if mpaa_mode == FILTER_ASK: + config.mpaa_ratings = self._answers.get("mpaa") + elif mpaa_mode == FILTER_PRESET: + config.mpaa_ratings = self._settings.get("preset_mpaa") + + # Runtime + runtime_mode = self._settings.get("runtime_mode", FILTER_SKIP) + if runtime_mode == FILTER_ASK: + rt = self._answers.get("runtime", {}) + config.runtime_min = rt.get("min", 0) + config.runtime_max = rt.get("max", 0) + elif runtime_mode == FILTER_PRESET: + config.runtime_min = self._settings.get("runtime_min", 0) + config.runtime_max = self._settings.get("runtime_max", 0) + + # Year + year_mode = self._settings.get("year_mode", FILTER_SKIP) + if year_mode == FILTER_ASK: + yr = self._answers.get("year", {}) + config.year_from = yr.get("from", 0) + config.year_to = yr.get("to", 0) + elif year_mode == FILTER_PRESET: + year_filter_type = self._settings.get("year_filter_type", 0) + if year_filter_type == YEAR_FILTER_RECENCY: + import datetime + recency = self._settings.get("year_recency", 5) + config.year_from = datetime.datetime.now().year - recency + else: + config.year_from = self._settings.get("year_from", 0) + config.year_to = self._settings.get("year_to", 0) + + # Score + score_mode = self._settings.get("score_mode", FILTER_SKIP) + if score_mode == FILTER_ASK: + config.min_score = self._answers.get("score", 0) + elif score_mode == FILTER_PRESET: + config.min_score = self._settings.get("min_score", 0) + + return config diff --git a/script.easymovie/resources/lib/utils.py b/script.easymovie/resources/lib/utils.py new file mode 100644 index 000000000..8fbae78ca --- /dev/null +++ b/script.easymovie/resources/lib/utils.py @@ -0,0 +1,428 @@ +""" +Shared utilities for EasyMovie. + +Provides structured logging, Kodi JSON-RPC helpers, settings accessors, +and notification utilities. + +Logging: + Logger: varies (factory function) + Key events: + - Varies by consumer module + See LOGGING.md for full guidelines. +""" +from __future__ import annotations + +import json +import os +import threading +import time +import traceback +from contextlib import contextmanager +from datetime import datetime as dt +from typing import Any, Dict, Generator, List, Optional, TextIO, Union + +import xbmc +import xbmcaddon +import xbmcgui +import xbmcvfs + +from resources.lib.constants import ( + ADDON_ID, + LOG_DIR, + LOG_FILENAME, + LOG_MAX_BYTES, + LOG_BACKUP_COUNT, + LOG_MAX_VALUE_LENGTH, + LOG_TIMESTAMP_FORMAT, + LOG_TIMESTAMP_TRIM, + NOTIFICATION_DURATION_MS, +) + + +# Singleton addon instance +_addon: Optional[xbmcaddon.Addon] = None + + +def get_addon(addon_id: Optional[str] = None) -> xbmcaddon.Addon: + """Get the addon instance (cached for default addon). + + Args: + addon_id: Optional addon ID. If None, returns the current addon. + If provided, creates a new instance (not cached). + + Returns: + The addon instance. + """ + global _addon + if addon_id is not None: + return xbmcaddon.Addon(addon_id) + if _addon is None: + try: + _addon = xbmcaddon.Addon() + except RuntimeError: + _addon = xbmcaddon.Addon(ADDON_ID) + return _addon + + +def get_setting(setting_id: str, addon_id: Optional[str] = None) -> str: + """Get a setting value as a string.""" + return get_addon(addon_id).getSetting(setting_id) + + +def get_bool_setting(setting_id: str, addon_id: Optional[str] = None) -> bool: + """Get a boolean setting value.""" + return get_setting(setting_id, addon_id) == 'true' + + +def get_int_setting(setting_id: str, addon_id: Optional[str] = None, default: int = 0) -> int: + """Get an integer setting value.""" + try: + return int(float(get_setting(setting_id, addon_id))) + except (ValueError, TypeError): + return default + + +def get_string_setting(setting_id: str, addon_id: Optional[str] = None) -> str: + """Get a string setting value.""" + return get_setting(setting_id, addon_id) + + +def lang(string_id: int, addon_id: Optional[str] = None) -> str: + """Get localized string.""" + return get_addon(addon_id).getLocalizedString(string_id) + + +def notify(message: str, duration: int = NOTIFICATION_DURATION_MS) -> None: + """Show a Kodi notification toast. + + Args: + message: The notification message. + duration: Duration in milliseconds. + """ + xbmcgui.Dialog().notification( + "EasyMovie", message, xbmcgui.NOTIFICATION_INFO, duration + ) + + +class StructuredLogger: + """Structured logging for EasyMovie addon. + + Output Routing: + - ERROR/WARNING/INFO: Kodi log (always) + easymovie.log (if debug enabled) + - DEBUG: easymovie.log only (never pollutes Kodi log) + + See LOGGING.md for full guidelines. + """ + + # Class-level shared state for file handling + _log_file: Optional[TextIO] = None + _log_file_path: Optional[str] = None + _log_file_size: int = 0 + _addon_id: str = ADDON_ID + _debug_enabled: bool = False + _initialized: bool = False + _lock: threading.Lock = threading.Lock() + + def __init__(self, module_name: str) -> None: + """Initialize a logger for a specific module.""" + self.module = module_name + + @classmethod + def initialize(cls, debug_enabled: bool, addon_id: str = ADDON_ID) -> None: + """Initialize the logging system. Idempotent.""" + with cls._lock: + cls._addon_id = addon_id + cls._debug_enabled = debug_enabled + + if cls._initialized: + return + + cls._initialized = True + + if debug_enabled: + cls._init_log_file() + + @classmethod + def _init_log_file(cls) -> None: + """Initialize log file with rotation. Must be called with lock held.""" + try: + log_dir = xbmcvfs.translatePath( + f"special://profile/addon_data/{cls._addon_id}/{LOG_DIR}/" + ) + + if not xbmcvfs.exists(log_dir): + xbmcvfs.mkdirs(log_dir) + + log_path = os.path.join(log_dir, LOG_FILENAME) + + existing_size = 0 + if xbmcvfs.exists(log_path): + try: + stat_result = xbmcvfs.Stat(log_path) + existing_size = stat_result.st_size() + except (OSError, AttributeError): + existing_size = 0 + + if existing_size > LOG_MAX_BYTES: + cls._rotate_logs(log_dir) + existing_size = 0 + + cls._log_file_path = log_path + try: + cls._log_file = open(log_path, "a", encoding="utf-8") + cls._log_file_size = existing_size + except (OSError, IOError): + cls._log_file = None + raise + except (OSError, IOError) as e: + xbmc.log( + f"[EasyMovie.logging] Failed to initialize log file: {e}", + xbmc.LOGWARNING + ) + cls._log_file = None + + @classmethod + def _rotate_logs(cls, log_dir: str) -> None: + """Rotate log files. Must be called with lock held.""" + try: + oldest = os.path.join(log_dir, f"easymovie.{LOG_BACKUP_COUNT}.log") + if xbmcvfs.exists(oldest): + xbmcvfs.delete(oldest) + + for i in range(LOG_BACKUP_COUNT - 1, 0, -1): + src = os.path.join(log_dir, f"easymovie.{i}.log") + dst = os.path.join(log_dir, f"easymovie.{i + 1}.log") + if xbmcvfs.exists(src): + xbmcvfs.rename(src, dst) + + current = os.path.join(log_dir, LOG_FILENAME) + if xbmcvfs.exists(current): + xbmcvfs.rename(current, os.path.join(log_dir, "easymovie.1.log")) + except (OSError, IOError): + pass + + @classmethod + def shutdown(cls) -> None: + """Close log file cleanly.""" + with cls._lock: + if cls._log_file: + try: + cls._log_file.close() + except (OSError, IOError): + pass + cls._log_file = None + cls._initialized = False + + def _format_message(self, message: str, **kwargs: Any) -> str: + """Format log message with optional key=value pairs.""" + base = f"[EasyMovie.{self.module}] {message}" + if kwargs: + pairs = [] + for k, v in kwargs.items(): + str_v = str(v) + if k != 'trace' and len(str_v) > LOG_MAX_VALUE_LENGTH: + str_v = str_v[:LOG_MAX_VALUE_LENGTH] + "..." + pairs.append(f"{k}={str_v}") + return f"{base} | {', '.join(pairs)}" + return base + + def _format_file_line(self, level: str, formatted_message: str) -> str: + """Format a log line for file output with timestamp.""" + timestamp = dt.now().strftime(LOG_TIMESTAMP_FORMAT)[:LOG_TIMESTAMP_TRIM] + return f"{timestamp} [{level:5}] {formatted_message}\n" + + def _write_to_file(self, level: str, formatted_message: str) -> None: + """Write to log file if enabled, with thread safety.""" + if not StructuredLogger._debug_enabled or StructuredLogger._log_file is None: + return + + line = self._format_file_line(level, formatted_message) + + with StructuredLogger._lock: + try: + if StructuredLogger._log_file is None: + return + + StructuredLogger._log_file.write(line) + StructuredLogger._log_file.flush() + StructuredLogger._log_file_size += len(line.encode("utf-8")) + + if StructuredLogger._log_file_size > LOG_MAX_BYTES: + StructuredLogger._log_file.close() + log_dir = os.path.dirname(StructuredLogger._log_file_path or "") + if log_dir: + StructuredLogger._rotate_logs(log_dir) + StructuredLogger._init_log_file() + except (IOError, OSError): + pass + + def _ensure_event(self, level: str, kwargs: Dict[str, Any]) -> Dict[str, Any]: + """Ensure event= is present for INFO/WARNING/ERROR logs.""" + if "event" not in kwargs: + kwargs["event"] = f"misc.{level}" + kwargs["_missing_event"] = True + return kwargs + + def debug(self, message: str, **kwargs: Any) -> None: + """Log debug-level message (file only when enabled).""" + if StructuredLogger._debug_enabled: + formatted = self._format_message(message, **kwargs) + self._write_to_file("DEBUG", formatted) + + def info(self, message: str, **kwargs: Any) -> None: + """Log info-level message (Kodi log + file).""" + kwargs = self._ensure_event("info", kwargs) + formatted = self._format_message(message, **kwargs) + xbmc.log(formatted, xbmc.LOGINFO) + self._write_to_file("INFO", formatted) + + def warning(self, message: str, **kwargs: Any) -> None: + """Log warning-level message (Kodi log + file).""" + kwargs = self._ensure_event("warning", kwargs) + formatted = self._format_message(message, **kwargs) + xbmc.log(formatted, xbmc.LOGWARNING) + self._write_to_file("WARN", formatted) + + def error(self, message: str, **kwargs: Any) -> None: + """Log error-level message (Kodi log + file).""" + kwargs = self._ensure_event("error", kwargs) + formatted = self._format_message(message, **kwargs) + xbmc.log(formatted, xbmc.LOGERROR) + self._write_to_file("ERROR", formatted) + + def exception(self, message: str, **kwargs: Any) -> None: + """Log error with automatic stack trace capture.""" + kwargs["trace"] = traceback.format_exc() + self.error(message, **kwargs) + + +def get_logger(module_name: str) -> StructuredLogger: + """Get a logger for the specified module. Auto-initializes on first call.""" + if not StructuredLogger._initialized: + try: + debug_enabled = get_bool_setting('logging') + except RuntimeError: + debug_enabled = False + + try: + addon_id = get_addon().getAddonInfo('id') + except RuntimeError: + addon_id = ADDON_ID + + StructuredLogger.initialize(debug_enabled=debug_enabled, addon_id=addon_id) + + return StructuredLogger(module_name) + + +class TimedOperation: + """Timer object for marking phases within a timed operation.""" + + def __init__(self, start_time: float) -> None: + self._start = start_time + self._phases: Dict[str, float] = {} + self._last_mark = start_time + + def mark(self, phase_name: str) -> None: + """Record elapsed time for a phase.""" + now = time.perf_counter() + self._phases[phase_name] = now - self._last_mark + self._last_mark = now + + def _get_phase_kwargs(self) -> Dict[str, int]: + """Get phase timings as keyword arguments.""" + return { + f"{name}_ms": int(duration * 1000) + for name, duration in self._phases.items() + } + + +@contextmanager +def log_timing( + logger: StructuredLogger, + operation: str, + **context: Any +) -> Generator[TimedOperation, None, None]: + """Context manager for timing operations. + + Args: + logger: The logger instance to use. + operation: Name of the operation being timed. + **context: Additional context to include in the log. + + Yields: + TimedOperation: Timer object with .mark(phase_name) method. + """ + start = time.perf_counter() + timer = TimedOperation(start) + try: + yield timer + finally: + elapsed_ms = int((time.perf_counter() - start) * 1000) + phase_kwargs = timer._get_phase_kwargs() + logger.debug( + f"{operation} completed", + duration_ms=elapsed_ms, + **phase_kwargs, + **context + ) + + +def invalidate_icon_cache(addon_id: str) -> None: + """Remove the addon icon from Kodi's texture cache. + + Kodi caches textures by file path. After replacing icon.png, + the old cached version persists until removed via JSON-RPC. + """ + _log = get_logger('default') + result = json_query({ + "jsonrpc": "2.0", + "method": "Textures.GetTextures", + "params": { + "filter": { + "field": "url", + "operator": "contains", + "value": addon_id, + } + }, + "id": 1, + }) + for texture in result.get("textures", []): + tid = texture.get("textureid") + if tid: + json_query({ + "jsonrpc": "2.0", + "method": "Textures.RemoveTexture", + "params": {"textureid": tid}, + "id": 1, + }, return_result=False) + _log.debug("Icon texture cache invalidated", event="icon.cache_clear", + addon_id=addon_id) + + +def json_query(query: Union[Dict[str, Any], List[Dict[str, Any]]], return_result: bool = True) -> Dict[str, Any]: + """Execute a JSON-RPC query against Kodi. + + Args: + query: The JSON-RPC query dictionary. + return_result: If True, return only the 'result' key. + + Returns: + The query result or empty dict on error. + """ + try: + request = json.dumps(query) + response = xbmc.executeJSONRPC(request) + data = json.loads(response) + + if return_result: + return data.get('result', {}) + return data + except (json.JSONDecodeError, KeyError, TypeError) as exc: + try: + _log = get_logger('data') + method = query.get("method", "unknown") if isinstance(query, dict) else "batch" + _log.warning("JSON-RPC query failed", event="jsonrpc.error", + method=method, error=str(exc)) + except Exception: + pass + return {} diff --git a/script.easymovie/resources/selector.py b/script.easymovie/resources/selector.py new file mode 100644 index 000000000..bcb00d033 --- /dev/null +++ b/script.easymovie/resources/selector.py @@ -0,0 +1,284 @@ +""" +Genre/MPAA/Playlist Selector for EasyMovie. + +Launched from settings via RunScript(script.easymovie,selector,genres), +RunScript(script.easymovie,selector,ignore_genres), +RunScript(script.easymovie,selector,mpaa), or +RunScript(script.easymovie,selector,playlist). + +Queries the Kodi library for all unique values, shows a multi-select +dialog, and saves the selection back to settings as JSON. + +Logging: + Logger: 'selector' + Key events: + - selector.open (INFO): Selector dialog opened + - selector.save (INFO): Selection saved + See LOGGING.md for full guidelines. +""" +from __future__ import annotations + +import json +import os +import sys +import xml.etree.ElementTree as ET +from typing import Optional + +import xbmcaddon +import xbmcvfs + +from resources.lib.utils import get_logger, json_query, lang +from resources.lib.data.queries import get_all_movies_query, get_playlist_files_query +from resources.lib.data.filters import extract_unique_genres, extract_unique_mpaa +from resources.lib.ui.dialogs import show_select_dialog + +log = get_logger('selector') + + +def _get_selector_type() -> str: + """Determine selector type from command-line arguments.""" + # When invoked via settings: argv = [script_path, 'selector', 'genres'|'mpaa'] + if len(sys.argv) >= 3: + return sys.argv[2] + # When invoked via default.py: argv = [script_path, 'selector', 'genres'|'mpaa'] + if len(sys.argv) >= 2: + return sys.argv[1] + return 'genres' + + +def _run_genre_selector() -> None: + """Show genre selection dialog and save to settings.""" + log.info("Opening genre selector", event="selector.open", type="genres") + + # Query all movies to extract genres + result = json_query(get_all_movies_query()) + movies = result.get("movies", []) + if not movies: + log.warning("No movies in library", event="selector.open") + return + + all_genres = extract_unique_genres(movies) + if not all_genres: + return + + # Load previously selected genres + addon = xbmcaddon.Addon() + saved_json = addon.getSetting('selected_genres') + saved_genres = [] + if saved_json: + try: + saved_genres = json.loads(saved_json) + except (json.JSONDecodeError, TypeError): + pass + + # Build preselected indices + preselected = [i for i, g in enumerate(all_genres) if g in saved_genres] + + # Show dialog + selected_indices = show_select_dialog( + heading=lang(32200), # "Select Genres" + items=all_genres, + multi_select=True, + preselected=preselected, + ) + + if selected_indices is None: + return # Cancelled + + # Save selection + selected_genres = [all_genres[i] for i in selected_indices] + addon.setSetting('selected_genres', json.dumps(selected_genres)) + + # Update display field + display = ", ".join(selected_genres) if selected_genres else "-" + addon.setSetting('selected_genres_display', display) + + log.info("Genres saved", event="selector.save", + count=len(selected_genres), genres=selected_genres[:5]) + + +def _run_ignore_genre_selector() -> None: + """Show ignore-genre selection dialog and save to settings.""" + log.info("Opening ignore genre selector", event="selector.open", + type="ignore_genres") + + result = json_query(get_all_movies_query()) + movies = result.get("movies", []) + if not movies: + log.warning("No movies in library", event="selector.open") + return + + all_genres = extract_unique_genres(movies) + if not all_genres: + return + + # Load previously selected ignore genres + addon = xbmcaddon.Addon() + saved_json = addon.getSetting('selected_ignore_genres') + saved_genres = [] + if saved_json: + try: + saved_genres = json.loads(saved_json) + except (json.JSONDecodeError, TypeError): + pass + + preselected = [i for i, g in enumerate(all_genres) if g in saved_genres] + + selected_indices = show_select_dialog( + heading=lang(32204), # "Ignore Genres" + items=all_genres, + multi_select=True, + preselected=preselected, + ) + + if selected_indices is None: + return # Cancelled + + selected_genres = [all_genres[i] for i in selected_indices] + addon.setSetting('selected_ignore_genres', json.dumps(selected_genres)) + + display = ", ".join(selected_genres) if selected_genres else "-" + addon.setSetting('ignore_genres_display', display) + + log.info("Ignore genres saved", event="selector.save", + count=len(selected_genres), genres=selected_genres[:5]) + + +def _run_mpaa_selector() -> None: + """Show MPAA rating selection dialog and save to settings.""" + log.info("Opening MPAA selector", event="selector.open", type="mpaa") + + result = json_query(get_all_movies_query()) + movies = result.get("movies", []) + if not movies: + return + + all_ratings = extract_unique_mpaa(movies) + if not all_ratings: + return + + # Load previously selected ratings + addon = xbmcaddon.Addon() + saved_json = addon.getSetting('selected_mpaa') + saved_ratings = [] + if saved_json: + try: + saved_ratings = json.loads(saved_json) + except (json.JSONDecodeError, TypeError): + pass + + preselected = [i for i, r in enumerate(all_ratings) if r in saved_ratings] + + selected_indices = show_select_dialog( + heading=lang(32201), # "Select Age Ratings" + items=all_ratings, + multi_select=True, + preselected=preselected, + ) + + if selected_indices is None: + return + + selected_ratings = [all_ratings[i] for i in selected_indices] + addon.setSetting('selected_mpaa', json.dumps(selected_ratings)) + + display = ", ".join(selected_ratings) if selected_ratings else "-" + addon.setSetting('selected_mpaa_display', display) + + log.info("MPAA ratings saved", event="selector.save", + count=len(selected_ratings)) + + +def _get_playlist_type(filepath: str) -> Optional[str]: + """Read a .xsp playlist file and return its type attribute. + + Returns: + Playlist type ('movies', 'tvshows', etc.) or None if unreadable. + """ + try: + fh = xbmcvfs.File(filepath, 'r') + try: + content = fh.read() + finally: + fh.close() + + if not content: + return None + + root = ET.fromstring(content) + return root.get('type') + except (ET.ParseError, Exception): + return None + + +def _run_playlist_selector() -> None: + """Show movie smart playlist selection and save to settings.""" + log.info("Opening playlist selector", event="selector.open", type="playlist") + + result = json_query(get_playlist_files_query()) + playlist_files = result.get('files') if result else None + + if not playlist_files: + log.warning("No playlists found", event="selector.open") + return + + # Filter to .xsp files with type 'movies' + names = [] + paths = [] + for item in playlist_files: + filepath = item.get('file', '') + label = item.get('label', '') + if not filepath.endswith('.xsp'): + continue + detected_type = _get_playlist_type(filepath) + if detected_type != 'movies': + continue + names.append(label) + paths.append(filepath) + + if not names: + log.warning("No movie playlists found", event="selector.open") + return + + selected_indices = show_select_dialog( + heading=lang(32203), # "Select Playlist" + items=names, + multi_select=False, + ) + + if selected_indices is None: + return + + idx = selected_indices[0] + selected_path = paths[idx] + filename = os.path.basename(selected_path) + if filename.endswith('.xsp'): + filename = filename[:-4] + + addon = xbmcaddon.Addon() + addon.setSetting('movie_pool_playlist_path', selected_path) + addon.setSetting('pool_display', filename) + + log.info("Playlist saved", event="selector.save", + path=selected_path, display=filename) + + +def main() -> None: + """Entry point for the selector script.""" + selector_type = _get_selector_type() + + if selector_type == 'genres': + _run_genre_selector() + elif selector_type == 'ignore_genres': + _run_ignore_genre_selector() + elif selector_type == 'mpaa': + _run_mpaa_selector() + elif selector_type == 'playlist': + _run_playlist_selector() + else: + log.warning("Unknown selector type", event="selector.open", + type=selector_type) + + +if __name__ == '__main__': + main() diff --git a/script.easymovie/resources/settings.xml b/script.easymovie/resources/settings.xml new file mode 100644 index 000000000..ce874839b --- /dev/null +++ b/script.easymovie/resources/settings.xml @@ -0,0 +1,752 @@ + + +
+ + + + + + + 0 + 2 + + + + + + + + + + + + + + 0 + 0 + + + + + + + + + + + + + 0 + RunScript(script.easymovie,set_icon) + + true + + + + + 0 + RunScript(script.easymovie,reset_icon) + + true + + + + + + + + + + + + + 0 + 2 + + + + + + + + + + + + 0 + 0 + + 1 + + + + + + + + + + + + 0 + + 1 + + RunScript(script.easymovie,selector,ignore_genres) + + true + + + + + 0 + - + + 1 + + false + + + + + 0 + 0 + + + + + + + + + + + + 0 + 0 + + 1 + + + + + + + + + + + + 0 + + 1 + + RunScript(script.easymovie,selector,genres) + + true + + + + + 0 + - + + 1 + + false + + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + 0 + + 1 + + + + + + + + + + + + + + + + 0 + 2 + + + + + + + + + + + + 0 + + 1 + + RunScript(script.easymovie,selector,mpaa) + + true + + + + + 0 + - + + 1 + + false + + + + + + + + 0 + 2 + + + + + + + + + + + + 0 + 0 + + 1 + + + 0 + 5 + 300 + + + + + + 0 + 0 + + 1 + + + 0 + 5 + 300 + + + + + + + + + 0 + 2 + + + + + + + + + + + + 0 + 0 + + 1 + + + + + + + + + + + + + + 0 + 2000 + + 1 + 3 + + + 1920 + 1 + 2030 + + + + + + 0 + 2026 + + 1 + 2 + + + 1920 + 1 + 2030 + + + + + + 0 + 5 + + 1 + 3 + + + 1 + 1 + 30 + + + + + + + + + 0 + 2 + + + + + + + + + + + + 0 + 0 + + 1 + + + 0 + 5 + 100 + + + + + + + + + + + + + 0 + true + + + + + 0 + true + + true + + + + + + + + 0 + true + + true + + + + + + 0 + 20 + + true + true + + + 5 + 5 + 60 + + + + + + 0 + 0 + + true + true + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + + + + + + + + + + + 0 + true + + + + + + + 0 + 10 + + 1 + 1 + 50 + + + + + + 0 + 0 + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + + 0 + 5 + + 1 + 1 + 20 + + + + + + 0 + 0 + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + 0 + true + + + + + 0 + true + + + + + + + + + + + + 0 + true + + + + + + + 0 + true + + + + + + + 0 + true + + + + + + + 1 + true + + + + + + + + + + + + 0 + false + + + + + 0 + + true + + RunScript(script.easymovie,selector,playlist) + + true + + + + + 0 + - + + true + + false + + + + + + + 0 + true + + + + + 0 + 3 + + true + + + + + + + + + + + + + + + + + + 0 + true + + + + + 0 + true + + + + + 0 + true + + true + + + + + + + + 0 + false + + + + + + + 0 + RunScript(script.easymovie,clone) + + true + + + + + + + + + + + + 4 + none + + + + + 4 + none + + + + + 4 + none + + + + + 4 + none + + + + + 4 + none + + + + +
+
diff --git a/script.easymovie/resources/settings_clone.xml b/script.easymovie/resources/settings_clone.xml new file mode 100644 index 000000000..12e1c5f65 --- /dev/null +++ b/script.easymovie/resources/settings_clone.xml @@ -0,0 +1,743 @@ + + + +
+ + + + + + + 0 + 2 + + + + + + + + + + + + + + 0 + 0 + + + + + + + + + + + + + 0 + RunScript(script.easymovie,set_icon) + + true + + + + + 0 + RunScript(script.easymovie,reset_icon) + + true + + + + + + + + + + + + + 0 + 2 + + + + + + + + + + + + 0 + 0 + + 1 + + + + + + + + + + + + 0 + + 1 + + RunScript(script.easymovie,selector,ignore_genres) + + true + + + + + 0 + - + + 1 + + false + + + + + 0 + 0 + + + + + + + + + + + + 0 + 0 + + 1 + + + + + + + + + + + + 0 + + 1 + + RunScript(script.easymovie,selector,genres) + + true + + + + + 0 + - + + 1 + + false + + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + 0 + + 1 + + + + + + + + + + + + + + + + 0 + 2 + + + + + + + + + + + + 0 + + 1 + + RunScript(script.easymovie,selector,mpaa) + + true + + + + + 0 + - + + 1 + + false + + + + + + + + 0 + 2 + + + + + + + + + + + + 0 + 0 + + 1 + + + 0 + 5 + 300 + + + + + + 0 + 0 + + 1 + + + 0 + 5 + 300 + + + + + + + + + 0 + 2 + + + + + + + + + + + + 0 + 0 + + 1 + + + + + + + + + + + + + + 0 + 2000 + + 1 + 3 + + + 1920 + 1 + 2030 + + + + + + 0 + 2026 + + 1 + 2 + + + 1920 + 1 + 2030 + + + + + + 0 + 5 + + 1 + 3 + + + 1 + 1 + 30 + + + + + + + + + 0 + 2 + + + + + + + + + + + + 0 + 0 + + 1 + + + 0 + 5 + 100 + + + + + + + + + + + + + 0 + true + + + + + 0 + true + + true + + + + + + + + 0 + true + + true + + + + + + 0 + 20 + + true + true + + + 5 + 5 + 60 + + + + + + 0 + 0 + + true + true + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + + + + + + + + + + + 0 + true + + + + + + + 0 + 10 + + 1 + 1 + 50 + + + + + + 0 + 0 + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + + 0 + 5 + + 1 + 1 + 20 + + + + + + 0 + 0 + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + 0 + true + + + + + 0 + true + + + + + + + + + + + + 0 + true + + + + + + + 0 + true + + + + + + + 0 + true + + + + + + + 1 + true + + + + + + + + + + + + 0 + false + + + + + 0 + + true + + RunScript(script.easymovie,selector,playlist) + + true + + + + + 0 + - + + true + + false + + + + + + + 0 + true + + + + + 0 + 3 + + true + + + + + + + + + + + + + + + + + + 0 + true + + + + + 0 + true + + + + + 0 + true + + true + + + + + + + + 0 + false + + + + + + + + + + + + 4 + none + + + + + 4 + none + + + + + 4 + none + + + + + 4 + none + + + + + 4 + none + + + + +
+
diff --git a/script.easymovie/resources/skins/Default/1080i/Includes.xml b/script.easymovie/resources/skins/Default/1080i/Includes.xml new file mode 100644 index 000000000..085d51b7c --- /dev/null +++ b/script.easymovie/resources/skins/Default/1080i/Includes.xml @@ -0,0 +1,104 @@ + + + + + + + 30 + 50 + 2 + + + + + + left + center + font13 + FFCCCCCC + + + + left + font13 + FFCCCCCC + true + + + + center + center + font12 + 30 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + 4dFFFFFF + false + + + + 400 + true + 0 + + + + common/white.png + + common/white.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/script.easymovie/resources/skins/Default/1080i/script-easymovie-BigScreenList.xml b/script.easymovie/resources/skins/Default/1080i/script-easymovie-BigScreenList.xml new file mode 100644 index 000000000..38f27becf --- /dev/null +++ b/script.easymovie/resources/skins/Default/1080i/script-easymovie-BigScreenList.xml @@ -0,0 +1,327 @@ + + + + !System.ScreenSaverActive + + + + + 0 + 0 + 1920 + 1080 + common/white.png + + + + + 80 + 15 + 1760 + 40 + font14_title + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 80 + 20 + 1760 + 30 + right + font10 + FF888888 + + + + + + 80 + 60 + 1760 + 2 + common/line_fade.png + + + + + Control.IsVisible(655) + + + + + + + + + + + + 25 + 72 + 1200 + 950 + + + + 35 + 8 + 1125 + 632 + $INFO[Container(655).ListItem.Art(fanart)] + scale + + + + + 35 + 650 + 1125 + 45 + font13 + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 35 + 700 + 1125 + 200 + font12 + FFAAAAAA + true + + + + + + 35 + 910 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + + 655 + 11 + common/white_rounded.png + common/white_rounded.png + + + + 265 + 910 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + + 655 + 10 + common/white_rounded.png + common/white_rounded.png + + + + + + 1260 + 72 + 630 + 930 + vertical + 3 + 200 + 10 + 655 + + + + + 10 + 5 + 53 + 80 + $INFO[ListItem.Art(poster)] + keep + + + 75 + 10 + 480 + 30 + center + font12 + FFCCCCCC + + + + + + 566 + 13 + 24 + 24 + common/IconWatched.png + !String.IsEmpty(ListItem.Property(watched)) + + + + + 598 + 13 + 24 + 24 + common/movieset.png + !String.IsEmpty(ListItem.Property(set_name)) + + + + + 75 + 42 + 540 + 25 + center + font10 + FF888888 + + !String.IsEmpty(ListItem.Mpaa) + + + + + 75 + 42 + 540 + 25 + center + font10 + FF888888 + + String.IsEmpty(ListItem.Mpaa) + + + + + + + 0 + 0 + 630 + 135 + common/white.png + + + 10 + 5 + 83 + 125 + $INFO[ListItem.Art(poster)] + keep + + + 105 + 10 + 450 + 30 + center + font12 + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 566 + 13 + 24 + 24 + common/IconWatched.png + !String.IsEmpty(ListItem.Property(watched)) + + + + + 598 + 13 + 24 + 24 + common/movieset.png + !String.IsEmpty(ListItem.Property(set_name)) + + + + + 105 + 42 + 510 + 25 + center + font10 + ffFFFFFF + + !String.IsEmpty(ListItem.Mpaa) + + + + + 105 + 42 + 510 + 25 + center + font10 + ffFFFFFF + + String.IsEmpty(ListItem.Mpaa) + + + 105 + 73 + 510 + 22 + center + font10 + ffFFFFFF + + + + 105 + 105 + 510 + 22 + center + font10 + $INFO[Window.Property(EasyMovie.Accent)] + + !String.IsEmpty(ListItem.Property(set_name)) + + + + + + diff --git a/script.easymovie/resources/skins/Default/1080i/script-easymovie-cardlist.xml b/script.easymovie/resources/skins/Default/1080i/script-easymovie-cardlist.xml new file mode 100644 index 000000000..cbb029dc6 --- /dev/null +++ b/script.easymovie/resources/skins/Default/1080i/script-easymovie-cardlist.xml @@ -0,0 +1,373 @@ + + + + 1 + !System.ScreenSaverActive + + + + + 0 + 0 + 1920 + 1080 + common/white.png + + + + + + + + + + + 260 + 140 + + + + + + + + + + + + -2 + -2 + 1404 + 804 + common/white_rounded.png + + + + + 0 + 0 + 1400 + 800 + common/menu.png + ffffffff + + + + + 30 + 13 + 1340 + 40 + font14_title + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 30 + 18 + 1340 + 25 + right + font10 + FF888888 + + + + + + 30 + 66 + 1340 + 2 + common/line_fade.png + + + + + 30 + 82 + 1340 + 638 + vertical + 200 + 655 + 655 + 655 + 10 + + + + + + 0 + 2 + 1340 + 74 + common/white.png + + + + + 10 + 4 + 47 + 70 + $INFO[ListItem.Art(poster)] + keep + + + + + 70 + 11 + 840 + 28 + center + font12 + FFCCCCCC + + + + + + 920 + 15 + 20 + 20 + common/IconWatched.png + !String.IsEmpty(ListItem.Property(watched)) + + + + + 948 + 15 + 20 + 20 + common/movieset.png + !String.IsEmpty(ListItem.Property(set_name)) + + + + + 1000 + 11 + 330 + 28 + right + center + font10 + FF888888 + + + + + + 70 + 42 + 850 + 26 + center + font10 + FFAAAAAA + + !String.IsEmpty(ListItem.Mpaa) + + + + + 70 + 42 + 850 + 26 + center + font10 + FFAAAAAA + + String.IsEmpty(ListItem.Mpaa) + + + + + 1000 + 42 + 330 + 26 + right + center + font10 + FF888888 + + + + + + + + + 0 + 0 + 1340 + 78 + common/white.png + + + + + 10 + 4 + 47 + 70 + $INFO[ListItem.Art(poster)] + keep + + + + + 70 + 11 + 840 + 28 + center + font12 + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 920 + 15 + 20 + 20 + common/IconWatched.png + !String.IsEmpty(ListItem.Property(watched)) + + + + + 948 + 15 + 20 + 20 + common/movieset.png + !String.IsEmpty(ListItem.Property(set_name)) + + + + + 1000 + 11 + 330 + 28 + right + center + font10 + ffFFFFFF + + + + + + 70 + 42 + 850 + 26 + center + font10 + ffFFFFFF + + !String.IsEmpty(ListItem.Mpaa) + + + + + 70 + 42 + 850 + 26 + center + font10 + ffFFFFFF + + String.IsEmpty(ListItem.Mpaa) + + + + + 1000 + 42 + 330 + 26 + right + center + font10 + ffFFFFFF + + + + + + + + 415 + 730 + 570 + 50 + + + + 0 + 0 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + + 655 + 11 + 11 + common/white_rounded.png + common/white_rounded.png + + + + + 230 + 0 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + + 655 + 10 + 10 + common/white_rounded.png + common/white_rounded.png + + + + + diff --git a/script.easymovie/resources/skins/Default/1080i/script-easymovie-confirm.xml b/script.easymovie/resources/skins/Default/1080i/script-easymovie-confirm.xml new file mode 100644 index 000000000..0571a288a --- /dev/null +++ b/script.easymovie/resources/skins/Default/1080i/script-easymovie-confirm.xml @@ -0,0 +1,148 @@ + + + + 1 + 10 + + + + + 0 + 0 + 1920 + 1080 + common/white.png + + + + + + + + + + + 600 + 420 + 720 + 240 + + + + + + + + + + + + -2 + -2 + 724 + 244 + common/white_rounded.png + + + + + 0 + 0 + 720 + 240 + common/menu.png + ffffffff + + + + + 30 + 13 + 660 + 40 + center + $INFO[Window.Property(EasyMovie.Accent)] + font14_title + + + + + + 30 + 66 + 660 + 2 + common/line_fade.png + + + + + 30 + 82 + 660 + 70 + center + center + ffFFFFFF + font13 + true + + + + + + 145 + 170 + 430 + 50 + center + 30 + horizontal + + + + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + common/white_rounded.png + common/white_rounded.png + + + + + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + !String.IsEqual(Window.Property(EasyMovie.SingleButton),true) + common/white_rounded.png + common/white_rounded.png + + + + + diff --git a/script.easymovie/resources/skins/Default/1080i/script-easymovie-contextwindow.xml b/script.easymovie/resources/skins/Default/1080i/script-easymovie-contextwindow.xml new file mode 100644 index 000000000..63119ab32 --- /dev/null +++ b/script.easymovie/resources/skins/Default/1080i/script-easymovie-contextwindow.xml @@ -0,0 +1,109 @@ + + + + 100000 + 100 + + + + + 0 + 0 + 1920 + 1080 + common/white.png + + + + + + + + + + + 720 + 455 + 480 + 170 + + + + + + + + + + + + -2 + -2 + 484 + 174 + common/white_rounded.png + + + + + 0 + 0 + 480 + 170 + common/menu.png + ffffffff + + + + + 30 + 25 + 420 + 120 + 12 + vertical + 100 + 100 + 100 + 100 + + + + 420 + 50 + left + center + font12 + 25 + ffFFFFFF + $INFO[Window.Property(EasyMovie.Accent)] + common/white_rounded.png + common/white_rounded.png + + + + + 420 + 50 + left + center + font12 + 25 + ffFFFFFF + $INFO[Window.Property(EasyMovie.Accent)] + common/white_rounded.png + common/white_rounded.png + + + + + + diff --git a/script.easymovie/resources/skins/Default/1080i/script-easymovie-continuation.xml b/script.easymovie/resources/skins/Default/1080i/script-easymovie-continuation.xml new file mode 100644 index 000000000..ce251d7f7 --- /dev/null +++ b/script.easymovie/resources/skins/Default/1080i/script-easymovie-continuation.xml @@ -0,0 +1,187 @@ + + + + 1 + 10 + + + + + 0 + 0 + 1920 + 1080 + common/white.png + + + + + + + + + + + 560 + 300 + 800 + 490 + + + + + + + + + + + + -2 + -2 + 804 + 494 + common/white_rounded.png + + + + + 0 + 0 + 800 + 490 + common/menu.png + ffffffff + + + + + 30 + 13 + 740 + 40 + center + $INFO[Window.Property(EasyMovie.Accent)] + font14_title + + + + + + 30 + 66 + 740 + 2 + common/line_fade.png + + + + + 30 + 82 + 160 + 240 + keep + - + + + + + 210 + 85 + 560 + 95 + left + ffFFFFFF + font13 + true + + + + + + 210 + 195 + 560 + 95 + left + FFAAAAAA + font12 + true + + + + + + 185 + 385 + 430 + 50 + + + + 0 + 0 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + 11 + 11 + common/white_rounded.png + common/white_rounded.png + + + + + 230 + 0 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + 10 + 10 + common/white_rounded.png + common/white_rounded.png + + + + + + 30 + 445 + 740 + 25 + center + center + FF888888 + font10 + + + + + diff --git a/script.easymovie/resources/skins/Default/1080i/script-easymovie-main.xml b/script.easymovie/resources/skins/Default/1080i/script-easymovie-main.xml new file mode 100644 index 000000000..46d75c295 --- /dev/null +++ b/script.easymovie/resources/skins/Default/1080i/script-easymovie-main.xml @@ -0,0 +1,339 @@ + + + + !System.ScreenSaverActive + + + + + 0 + 0 + 1920 + 1080 + common/white.png + + + + + Control.IsVisible(655) + + + + + + + + + + + + 40 + 15 + 1140 + 40 + font14_title + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 40 + 20 + 1840 + 30 + right + font10 + FF888888 + + + + + + 40 + 60 + 1840 + 2 + common/line_fade.png + + + + + 40 + 72 + 660 + 960 + + + + 0 + 0 + 620 + 349 + $INFO[Container(655).ListItem.Art(fanart)] + scale + + + + + 0 + 357 + 620 + 2 + common/line_fade.png + + + + + 0 + 369 + 620 + 45 + font13 + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 0 + 419 + 620 + 30 + font12 + FFCCCCCC + + !String.IsEmpty(Container(655).ListItem.Mpaa) + + + + + 0 + 419 + 620 + 30 + font12 + FFCCCCCC + + String.IsEmpty(Container(655).ListItem.Mpaa) + + + + + 0 + 454 + 620 + 25 + font10 + FFAAAAAA + + + + + + 0 + 484 + 620 + 25 + font10 + $INFO[Window.Property(EasyMovie.Accent)] + + !String.IsEmpty(Container(655).ListItem.Property(set_name)) + + + + + 0 + 490 + 620 + 2 + common/line_fade.png + String.IsEmpty(Container(655).ListItem.Property(set_name)) + + + + + 0 + 519 + 620 + 2 + common/line_fade.png + !String.IsEmpty(Container(655).ListItem.Property(set_name)) + + + + + 0 + 502 + 620 + 395 + font12 + FFAAAAAA + true + + String.IsEmpty(Container(655).ListItem.Property(set_name)) + + + + + 0 + 531 + 620 + 370 + font12 + FFAAAAAA + true + + !String.IsEmpty(Container(655).ListItem.Property(set_name)) + + + + + 0 + 910 + 620 + 50 + + + 0 + 0 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + + 655 + 11 + 11 + common/white_rounded.png + common/white_rounded.png + + + + 230 + 0 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + + 655 + 10 + 10 + common/white_rounded.png + common/white_rounded.png + + + + + + + 720 + 72 + 1160 + 975 + vertical + 10 + 655 + 655 + 655 + 6 + 200 + + + + + + 15 + 20 + 190 + 285 + $INFO[ListItem.Art(poster)] + scale + + + + + 19 + 24 + 24 + 24 + common/IconWatched.png + !String.IsEmpty(ListItem.Property(watched)) + + + + + 177 + 24 + 24 + 24 + common/movieset.png + !String.IsEmpty(ListItem.Property(set_name)) + + + + + + + + 12 + 17 + 196 + 291 + common/white.png + + + + + 15 + 20 + 190 + 285 + $INFO[ListItem.Art(poster)] + scale + + + + + 19 + 24 + 24 + 24 + common/IconWatched.png + !String.IsEmpty(ListItem.Property(watched)) + + + + + 177 + 24 + 24 + 24 + common/movieset.png + !String.IsEmpty(ListItem.Property(set_name)) + + + + + + diff --git a/script.easymovie/resources/skins/Default/1080i/script-easymovie-postergrid.xml b/script.easymovie/resources/skins/Default/1080i/script-easymovie-postergrid.xml new file mode 100644 index 000000000..e8e8bb364 --- /dev/null +++ b/script.easymovie/resources/skins/Default/1080i/script-easymovie-postergrid.xml @@ -0,0 +1,300 @@ + + + + 655 + !System.ScreenSaverActive + + + + + 0 + 0 + 1920 + 1080 + common/white.png + + + + + Control.IsVisible(655) + + + + + + + + + + + + + + 40 + 15 + 800 + 40 + font14_title + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 40 + 20 + 1840 + 30 + right + font10 + FF888888 + + + + + + 40 + 60 + 1840 + 2 + common/line_fade.png + + + + + 40 + 72 + 1840 + 790 + horizontal + 1 + 300 + 655 + 10 + 655 + 655 + 6 + + + + + + 14 + 258 + 182 + 273 + scale + $INFO[ListItem.Art(poster)] + + + + + 18 + 262 + 24 + 24 + common/IconWatched.png + !String.IsEmpty(ListItem.Property(watched)) + + + + + 168 + 262 + 24 + 24 + common/movieset.png + !String.IsEmpty(ListItem.Property(set_name)) + + + + + + + + + + + + + + + + 7 + 77 + 426 + 636 + common/white.png + + + + + 10 + 80 + 420 + 630 + scale + $INFO[ListItem.Art(poster)] + + + + + 14 + 84 + 40 + 40 + common/IconWatched.png + !String.IsEmpty(ListItem.Property(watched)) + + + + + 386 + 84 + 40 + 40 + common/movieset.png + !String.IsEmpty(ListItem.Property(set_name)) + + + + + + + + + + 40 + 870 + 1840 + 2 + common/line_fade.png + + + + + 40 + 882 + 700 + 35 + font13 + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 40 + 922 + 700 + 24 + font10 + FFCCCCCC + + !String.IsEmpty(Container(655).ListItem.Mpaa) + + + + + 40 + 922 + 700 + 24 + font10 + FFCCCCCC + + String.IsEmpty(Container(655).ListItem.Mpaa) + + + + + 40 + 952 + 700 + 24 + font10 + FFAAAAAA + + + + + + 40 + 982 + 700 + 24 + font10 + $INFO[Window.Property(EasyMovie.Accent)] + + !String.IsEmpty(Container(655).ListItem.Property(set_name)) + + + + + 800 + 882 + 1080 + 180 + font12 + FFAAAAAA + + true + + + + + 40 + 1020 + 200 + 45 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + + 655 + 11 + 11 + common/white_rounded.png + common/white_rounded.png + + + + + 270 + 1020 + 200 + 45 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + + 655 + 10 + 10 + common/white_rounded.png + common/white_rounded.png + + + + diff --git a/script.easymovie/resources/skins/Default/1080i/script-easymovie-select.xml b/script.easymovie/resources/skins/Default/1080i/script-easymovie-select.xml new file mode 100644 index 000000000..eb1598f67 --- /dev/null +++ b/script.easymovie/resources/skins/Default/1080i/script-easymovie-select.xml @@ -0,0 +1,291 @@ + + + + 1 + 11 + + + + + 0 + 0 + 1920 + 1080 + common/white.png + + + + + + + + + + + 660 + 250 + 600 + 580 + + + + + + + + + + + + -2 + -2 + 604 + 584 + common/white_rounded.png + + + + + 0 + 0 + 600 + 580 + common/menu.png + ffffffff + + + + + 30 + 13 + 540 + 40 + center + $INFO[Window.Property(EasyMovie.Accent)] + font14_title + + + + + + 30 + 66 + 540 + 2 + common/line_fade.png + + + + + 30 + 82 + 540 + 420 + vertical + 200 + 100 + 10 + 100 + 11 + + + + + + 0 + 0 + 540 + 50 + center + center + font10 + 66FFFFFF + + String.IsEqual(ListItem.Property(is_header),true) + + + + + + 10 + 13 + 24 + 24 + check_off.png + [!String.IsEqual(ListItem.Property(checked),true)] + !String.IsEqual(Window.Property(EasyMovie.SingleSelect),true) + !String.IsEqual(ListItem.Property(is_header),true) + + + + 10 + 13 + 24 + 24 + check_on.png + [String.IsEqual(ListItem.Property(checked),true)] + !String.IsEqual(Window.Property(EasyMovie.SingleSelect),true) + !String.IsEqual(ListItem.Property(is_header),true) + + + + + 48 + 0 + 480 + 50 + center + font12 + FFCCCCCC + + !String.IsEqual(Window.Property(EasyMovie.SingleSelect),true) + !String.IsEqual(ListItem.Property(is_header),true) + + + + 15 + 0 + 510 + 50 + center + font12 + FFCCCCCC + + String.IsEqual(Window.Property(EasyMovie.SingleSelect),true) + !String.IsEqual(ListItem.Property(is_header),true) + + + + + + + + 0 + 0 + 540 + 50 + common/white.png + !String.IsEqual(ListItem.Property(is_header),true) + + + + + 0 + 0 + 540 + 50 + center + center + font10 + 66FFFFFF + + String.IsEqual(ListItem.Property(is_header),true) + + + + + + 10 + 13 + 24 + 24 + check_off.png + [!String.IsEqual(ListItem.Property(checked),true)] + !String.IsEqual(Window.Property(EasyMovie.SingleSelect),true) + !String.IsEqual(ListItem.Property(is_header),true) + + + + 10 + 13 + 24 + 24 + check_on.png + [String.IsEqual(ListItem.Property(checked),true)] + !String.IsEqual(Window.Property(EasyMovie.SingleSelect),true) + !String.IsEqual(ListItem.Property(is_header),true) + + + + + 48 + 0 + 480 + 50 + center + font12 + $INFO[Window.Property(EasyMovie.Accent)] + + !String.IsEqual(Window.Property(EasyMovie.SingleSelect),true) + !String.IsEqual(ListItem.Property(is_header),true) + + + + 15 + 0 + 510 + 50 + center + font12 + $INFO[Window.Property(EasyMovie.Accent)] + + String.IsEqual(Window.Property(EasyMovie.SingleSelect),true) + !String.IsEqual(ListItem.Property(is_header),true) + + + + + + + 115 + 515 + 370 + 50 + center + 30 + horizontal + 100 + + + + 170 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + 100 + + common/white_rounded.png + common/white_rounded.png + + + + + 170 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + 100 + !String.IsEqual(Window.Property(EasyMovie.SingleSelect),true) + + common/white_rounded.png + common/white_rounded.png + + + + + diff --git a/script.easymovie/resources/skins/Default/1080i/script-easymovie-setwarning.xml b/script.easymovie/resources/skins/Default/1080i/script-easymovie-setwarning.xml new file mode 100644 index 000000000..9b4088144 --- /dev/null +++ b/script.easymovie/resources/skins/Default/1080i/script-easymovie-setwarning.xml @@ -0,0 +1,173 @@ + + + + 1 + 10 + + + + + 0 + 0 + 1920 + 1080 + common/white.png + + + + + + + + + + + 560 + 300 + 800 + 480 + + + + + + + + + + + + -2 + -2 + 804 + 484 + common/white_rounded.png + + + + + 0 + 0 + 800 + 480 + common/menu.png + ffffffff + + + + + 30 + 13 + 740 + 40 + center + $INFO[Window.Property(EasyMovie.Accent)] + font14_title + + + + + + 30 + 66 + 740 + 2 + common/line_fade.png + + + + + 30 + 82 + 160 + 240 + keep + - + + + + + 210 + 85 + 560 + 165 + left + ffFFFFFF + font13 + true + + + + + + 30 + 340 + 740 + 50 + center + FFAAAAAA + font12 + true + + + + + + 185 + 415 + 430 + 50 + + + + 0 + 0 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + 11 + 11 + common/white_rounded.png + common/white_rounded.png + + + + + 230 + 0 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + 10 + 10 + common/white_rounded.png + common/white_rounded.png + + + + + diff --git a/script.easymovie/resources/skins/Default/1080i/script-easymovie-splitlist.xml b/script.easymovie/resources/skins/Default/1080i/script-easymovie-splitlist.xml new file mode 100644 index 000000000..27b1e2e7f --- /dev/null +++ b/script.easymovie/resources/skins/Default/1080i/script-easymovie-splitlist.xml @@ -0,0 +1,442 @@ + + + + 1 + !System.ScreenSaverActive + + + + + 0 + 0 + 1920 + 1080 + common/white.png + + + + + + + + + + + 260 + 140 + + + + + + + + + + + + -2 + -2 + 1404 + 804 + common/white_rounded.png + + + + + 0 + 0 + 1400 + 800 + common/menu.png + ffffffff + + + + + Control.IsVisible(655) + + + + 30 + 13 + 730 + + + + 0 + 0 + 730 + 40 + font14_title + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 0 + 5 + 1340 + 30 + right + font10 + FF888888 + + + + + + + + 30 + 66 + 1340 + 2 + common/line_fade.png + + + + + 30 + 82 + 730 + 648 + vertical + 200 + 655 + 655 + 655 + 10 + + + + + + 15 + 6 + 530 + 28 + center + font12 + FFCCCCCC + + + + + + 15 + 38 + 530 + 24 + center + font10 + FF888888 + + !String.IsEmpty(ListItem.Mpaa) + + + + + 15 + 38 + 530 + 24 + center + font10 + FF888888 + + String.IsEmpty(ListItem.Mpaa) + + + + + 670 + 19 + 24 + 24 + common/IconWatched.png + !String.IsEmpty(ListItem.Property(watched)) + + + + + 700 + 19 + 24 + 24 + common/movieset.png + !String.IsEmpty(ListItem.Property(set_name)) + + + + + + + + 0 + 0 + 730 + 68 + common/white.png + + + + + 15 + 6 + 530 + 28 + center + font12 + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 15 + 38 + 530 + 24 + center + font10 + ffFFFFFF + + !String.IsEmpty(ListItem.Mpaa) + + + + + 15 + 38 + 530 + 24 + center + font10 + ffFFFFFF + + String.IsEmpty(ListItem.Mpaa) + + + + + 670 + 19 + 24 + 24 + common/IconWatched.png + !String.IsEmpty(ListItem.Property(watched)) + + + + + 700 + 19 + 24 + 24 + common/movieset.png + !String.IsEmpty(ListItem.Property(set_name)) + + + + + + + 165 + 740 + 570 + 50 + + + + 0 + 0 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + + 655 + 11 + 11 + common/white_rounded.png + common/white_rounded.png + + + + + 230 + 0 + 200 + 50 + center + center + font12 + 10 + FFFFFFFF + $INFO[Window.Property(EasyMovie.ButtonTextFocused)] + + 655 + 10 + 10 + common/white_rounded.png + common/white_rounded.png + + + + + + 780 + 13 + 590 + + + + 185 + 68 + 220 + 330 + $INFO[Container(655).ListItem.Art(poster)] + keep + + + + + 0 + 413 + 590 + 2 + common/line_fade.png + + + + + 0 + 421 + 590 + 35 + center + font13 + $INFO[Window.Property(EasyMovie.Accent)] + + + + + + 0 + 458 + 590 + 28 + center + font10 + FFCCCCCC + + !String.IsEmpty(Container(655).ListItem.Mpaa) + + + + + 0 + 458 + 590 + 28 + center + font10 + FFCCCCCC + + String.IsEmpty(Container(655).ListItem.Mpaa) + + + + + 0 + 488 + 590 + 28 + center + true + font10 + FFAAAAAA + + + + + + 0 + 518 + 590 + 28 + center + font10 + $INFO[Window.Property(EasyMovie.Accent)] + + !String.IsEmpty(Container(655).ListItem.Property(set_name)) + + + + + 0 + 521 + 590 + 2 + common/line_fade.png + String.IsEmpty(Container(655).ListItem.Property(set_name)) + + + + + 0 + 550 + 590 + 2 + common/line_fade.png + !String.IsEmpty(Container(655).ListItem.Property(set_name)) + + + + + 0 + 533 + 590 + 250 + font10 + FFCCCCCC + true + + String.IsEmpty(Container(655).ListItem.Property(set_name)) + + + + + 0 + 562 + 590 + 212 + font10 + FFCCCCCC + true + + !String.IsEmpty(Container(655).ListItem.Property(set_name)) + + + + + + diff --git a/script.easymovie/resources/skins/Default/media/check_off.png b/script.easymovie/resources/skins/Default/media/check_off.png new file mode 100644 index 000000000..74757301d Binary files /dev/null and b/script.easymovie/resources/skins/Default/media/check_off.png differ diff --git a/script.easymovie/resources/skins/Default/media/check_on.png b/script.easymovie/resources/skins/Default/media/check_on.png new file mode 100644 index 000000000..afb4c97d9 Binary files /dev/null and b/script.easymovie/resources/skins/Default/media/check_on.png differ diff --git a/script.easymovie/resources/skins/Default/media/common/IconWatched.png b/script.easymovie/resources/skins/Default/media/common/IconWatched.png new file mode 100644 index 000000000..6469bb01b Binary files /dev/null and b/script.easymovie/resources/skins/Default/media/common/IconWatched.png differ diff --git a/script.easymovie/resources/skins/Default/media/common/gradient_bottom.png b/script.easymovie/resources/skins/Default/media/common/gradient_bottom.png new file mode 100644 index 000000000..19451ac25 Binary files /dev/null and b/script.easymovie/resources/skins/Default/media/common/gradient_bottom.png differ diff --git a/script.easymovie/resources/skins/Default/media/common/line_fade.png b/script.easymovie/resources/skins/Default/media/common/line_fade.png new file mode 100644 index 000000000..15b328feb Binary files /dev/null and b/script.easymovie/resources/skins/Default/media/common/line_fade.png differ diff --git a/script.easymovie/resources/skins/Default/media/common/menu.png b/script.easymovie/resources/skins/Default/media/common/menu.png new file mode 100644 index 000000000..058f52792 Binary files /dev/null and b/script.easymovie/resources/skins/Default/media/common/menu.png differ diff --git a/script.easymovie/resources/skins/Default/media/common/movieset.png b/script.easymovie/resources/skins/Default/media/common/movieset.png new file mode 100644 index 000000000..f0c5d1875 Binary files /dev/null and b/script.easymovie/resources/skins/Default/media/common/movieset.png differ diff --git a/script.easymovie/resources/skins/Default/media/common/scrim_topleft.png b/script.easymovie/resources/skins/Default/media/common/scrim_topleft.png new file mode 100644 index 000000000..7367a776b Binary files /dev/null and b/script.easymovie/resources/skins/Default/media/common/scrim_topleft.png differ diff --git a/script.easymovie/resources/skins/Default/media/common/scrim_topright.png b/script.easymovie/resources/skins/Default/media/common/scrim_topright.png new file mode 100644 index 000000000..2877d0924 Binary files /dev/null and b/script.easymovie/resources/skins/Default/media/common/scrim_topright.png differ diff --git a/script.easymovie/resources/skins/Default/media/common/white.png b/script.easymovie/resources/skins/Default/media/common/white.png new file mode 100644 index 000000000..19b1fe95d Binary files /dev/null and b/script.easymovie/resources/skins/Default/media/common/white.png differ diff --git a/script.easymovie/resources/skins/Default/media/common/white_rounded.png b/script.easymovie/resources/skins/Default/media/common/white_rounded.png new file mode 100644 index 000000000..9c0883847 Binary files /dev/null and b/script.easymovie/resources/skins/Default/media/common/white_rounded.png differ diff --git a/script.easymovie/resources/update_clone.py b/script.easymovie/resources/update_clone.py new file mode 100644 index 000000000..a76e13f13 --- /dev/null +++ b/script.easymovie/resources/update_clone.py @@ -0,0 +1,280 @@ +""" +EasyMovie Clone Update. + +Updates an existing clone to match the current main EasyMovie version. + +This script is self-contained and does NOT import from resources.lib. +RunScript() executes from the resources/ folder context, which breaks +the normal import paths used elsewhere in the addon. + +Update Process: + 1. Copy fresh files from main EasyMovie to temp folder + 2. Remove clone-only files, replace with clone templates + 3. Update addon ID references in settings, scripts, skins, language + 4. Remove old clone, move temp to final location + 5. Re-register with Kodi + 6. Set window property flag to prevent update loop + +Arguments (via sys.argv): + 1. src_path: Path to main EasyMovie installation + 2. clone_path: Path to clone addon folder + 3. clone_id: Sanitized addon ID (e.g., script.easymovie.kids_movies) + 4. clone_name: Human-readable name (e.g., "Kids Movies") + +Logging: + Uses xbmc.log() directly (not StructuredLogger) due to import constraints. +""" +import os +import shutil +import sys +from typing import Optional +from xml.etree import ElementTree as ET + +import xbmc +import xbmcaddon +import xbmcgui +import xbmcvfs + +# Constants (inlined to avoid import issues) +ADDON_NAME = "EasyMovie" + +# Get main addon for version info and localized strings +_main_addon = xbmcaddon.Addon('script.easymovie') +dialog = xbmcgui.Dialog() + + +def _log(message: str, level: int = xbmc.LOGINFO) -> None: + """Simple logging wrapper.""" + xbmc.log(f"[EasyMovie.update_clone] {message}", level) + + +def _lang(string_id: int) -> str: + """Get localized string from main addon.""" + return _main_addon.getLocalizedString(string_id) + + +def _error_and_exit( + exception: Exception, + path_to_clean: Optional[str] = None, +) -> None: + """Handle errors during update.""" + _log(f"Clone update failed: {exception}", xbmc.LOGERROR) + dialog.ok(ADDON_NAME, _lang(32715) + '\n' + _lang(32708)) + if path_to_clean: + shutil.rmtree(path_to_clean, ignore_errors=True) + sys.exit() + + +def _replace_in_file(filepath: str, old: str, new: str) -> None: + """Replace string in a file.""" + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content.replace(old, new)) + + +def run_update( + src_path: str, + clone_path: str, + clone_id: str, + clone_name: str, +) -> None: + """Run the clone update. + + Args: + src_path: Path to main EasyMovie installation. + clone_path: Path to clone addon folder. + clone_id: Sanitized addon ID (e.g., script.easymovie.kids_movies). + clone_name: Human-readable name (e.g., "Kids Movies"). + """ + parent_version = _main_addon.getAddonInfo('version') + + # Use temp folder for atomic operation + temp_base = xbmcvfs.translatePath('special://temp/') + temp_path = os.path.join(temp_base, f'easymovie_update_{clone_id}') + + progress = xbmcgui.DialogProgress() + progress.create(ADDON_NAME, "Updating clone...") + + try: + # Clean up leftover temp + if os.path.isdir(temp_path): + shutil.rmtree(temp_path, ignore_errors=True) + + progress.update(10, "Copying addon files...") + ignore = shutil.ignore_patterns( + '.pyc', '.git*', '__pycache__', 'CVS', '.svn', + 'docs', 'tests', '.claude*', '.mcp.json', + 'CLAUDE.md', 'LOGGING.md', 'CONTRIBUTING.md', 'README.md', + 'pyrightconfig.json', '.pyflakes', '.ruff_cache', + '.pytest_cache', 'conftest.py', 'pytest.ini', + '_temp', '.worktrees', + ) + shutil.copytree(src_path, temp_path, ignore=ignore) + + progress.update(25, "Configuring clone...") + # Remove original addon.xml and settings.xml + addon_xml = os.path.join(temp_path, 'addon.xml') + os.remove(addon_xml) + os.remove(os.path.join(temp_path, 'resources', 'settings.xml')) + + # Move clone templates into place + shutil.move( + os.path.join(temp_path, 'resources', 'addon_clone.xml'), + addon_xml, + ) + shutil.move( + os.path.join(temp_path, 'resources', 'settings_clone.xml'), + os.path.join(temp_path, 'resources', 'settings.xml'), + ) + + # Remove clone-only files (clones don't run background services) + for remove_file in [ + 'resources/clone.py', 'resources/update_clone.py', 'service.py', + ]: + path = os.path.join(temp_path, remove_file) + if os.path.exists(path): + os.remove(path) + + progress.update(35, "Updating addon metadata...") + # Parse and update addon.xml + tree = ET.parse(addon_xml) + root = tree.getroot() + root.set('id', clone_id) + root.set('name', clone_name) + root.set('version', parent_version) + summary_elem = tree.find('.//summary') + if summary_elem is not None: + summary_elem.text = clone_name + tree.write(addon_xml, encoding='unicode', xml_declaration=True) + + progress.update(45, "Updating settings...") + settings_file = os.path.join(temp_path, 'resources', 'settings.xml') + _replace_in_file(settings_file, 'script.easymovie', clone_id) + + progress.update(55, "Updating language files...") + language_dir = os.path.join(temp_path, 'resources', 'language') + for lang_folder in os.listdir(language_dir): + strings_file = os.path.join(language_dir, lang_folder, 'strings.po') + if os.path.isfile(strings_file): + with open(strings_file, 'r', encoding='utf-8') as f: + content = f.read() + content = content.replace( + '# Addon Name: EasyMovie', f'# Addon Name: {clone_name}' + ) + content = content.replace( + '# Addon id: script.easymovie', f'# Addon id: {clone_id}' + ) + with open(strings_file, 'w', encoding='utf-8') as f: + f.write(content) + + progress.update(65, "Updating scripts...") + # Rewrite default.py + default_py = os.path.join(temp_path, 'default.py') + with open(default_py, 'w', encoding='utf-8') as f: + f.write( + '"""EasyMovie clone entry point."""\n' + 'from resources.lib.ui.main import main, _handle_entry_args\n' + '\n' + 'try:\n' + f' if not _handle_entry_args("{clone_id}"):\n' + f' main(addon_id="{clone_id}")\n' + 'except SystemExit:\n' + ' pass\n' + 'except Exception:\n' + ' try:\n' + ' from resources.lib.utils import get_logger\n' + " log = get_logger('default')\n" + ' log.exception("Unhandled error in EasyMovie clone",' + ' event="launch.crash")\n' + ' except Exception:\n' + ' import traceback\n' + ' import xbmc\n' + ' xbmc.log(\n' + ' f"[EasyMovie] Unhandled error:' + ' {traceback.format_exc()}",\n' + ' xbmc.LOGERROR,\n' + ' )\n' + ) + + # Update selector.py + selector_py = os.path.join(temp_path, 'resources', 'selector.py') + if os.path.isfile(selector_py): + _replace_in_file(selector_py, 'script.easymovie', clone_id) + + progress.update(75, "Updating skins...") + skin_dir = os.path.join( + temp_path, 'resources', 'skins', 'Default', '1080i' + ) + for filename in os.listdir(skin_dir): + if filename.endswith('.xml'): + skin_file = os.path.join(skin_dir, filename) + _replace_in_file( + skin_file, + '$ADDON[script.easymovie ', + f'$ADDON[{clone_id} ', + ) + + # Restore custom icon if user had one + custom_icon = xbmcvfs.translatePath( + f'special://profile/addon_data/{clone_id}/custom_icon.png' + ) + if os.path.isfile(custom_icon): + shutil.copy2(custom_icon, os.path.join(temp_path, 'icon.png')) + _log(f"Restored custom icon for {clone_id}") + + progress.update(85, "Installing updated clone...") + # Remove old clone, move new one into place + if os.path.isdir(clone_path): + shutil.rmtree(clone_path) + shutil.move(temp_path, clone_path) + + except Exception as e: + if os.path.isdir(temp_path): + shutil.rmtree(temp_path, ignore_errors=True) + progress.close() + _error_and_exit(e, clone_path) + + # Register with Kodi + try: + progress.update(90, "Registering with Kodi...") + xbmc.executebuiltin('UpdateLocalAddons') + xbmc.sleep(3000) + + progress.update(95, "Enabling clone...") + xbmc.executeJSONRPC( + '{"jsonrpc":"2.0","method":"Addons.SetAddonEnabled",' + f'"id":1,"params":{{"addonid":"{clone_id}","enabled":false}}}}' + ) + xbmc.sleep(1000) + xbmc.executeJSONRPC( + '{"jsonrpc":"2.0","method":"Addons.SetAddonEnabled",' + f'"id":1,"params":{{"addonid":"{clone_id}","enabled":true}}}}' + ) + except Exception: + _log(f"Addon re-registration failed: {clone_id}", xbmc.LOGWARNING) + + # Set flag to prevent update loop (Kodi cache may still report old version) + progress.update(95, "Finalizing...") + xbmcgui.Window(10000).setProperty( + f'EasyMovie.UpdateComplete.{clone_id}', parent_version + ) + _log(f"Set update complete flag: {clone_id} -> {parent_version}") + + progress.update(100, "Complete!") + xbmc.sleep(500) + progress.close() + + dialog.ok(ADDON_NAME, _lang(32713) + '\n' + _lang(32714)) + + +if __name__ == "__main__": + _src_path = sys.argv[1] + _clone_path = sys.argv[2] + _clone_id = sys.argv[3] + _clone_name = sys.argv[4] + + _log(f"Clone update started: {_clone_name}") + run_update(_src_path, _clone_path, _clone_id, _clone_name) + _log(f"Clone update completed: {_clone_name} -> " + f"{_main_addon.getAddonInfo('version')}") diff --git a/script.easymovie/service.py b/script.easymovie/service.py new file mode 100644 index 000000000..feb05a0b6 --- /dev/null +++ b/script.easymovie/service.py @@ -0,0 +1,5 @@ +"""EasyMovie Background Service Entry Point.""" + +if __name__ == "__main__": + from resources.lib.service.main import main + main()