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()