diff --git a/service.plexskipintro/.github/ISSUE_TEMPLATE/config.yml b/service.plexskipintro/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..ca807e40b1 --- /dev/null +++ b/service.plexskipintro/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Kodi forums, add-ons section + url: https://forum.kodi.tv/forumdisplay.php?fid=27 + about: Please ask and answer questions here. + - name: Pull requests + url: https://github.com/xbmc/repo-scripts/pulls + about: When you want to submit a new or updated add-on, please open a pull request here. \ No newline at end of file diff --git a/service.plexskipintro/.github/ISSUE_TEMPLATE/violating_addon.md b/service.plexskipintro/.github/ISSUE_TEMPLATE/violating_addon.md new file mode 100644 index 0000000000..bd7edafadb --- /dev/null +++ b/service.plexskipintro/.github/ISSUE_TEMPLATE/violating_addon.md @@ -0,0 +1,34 @@ +--- +name: Report a violating add-on +about: Report an add-on that violates the Kodi add-on rules. +title: '' +labels: 'violation' +assignees: '' + +--- + + + + + +### Add-on details: + +- Add-on name: +- Add-on ID: +- Version number: +- Kodi/repository version: + + + +### Rules that have been violated by the add-on: + + + +### Explain why the add-on is violating these rules: + + diff --git a/service.plexskipintro/LICENSE.txt b/service.plexskipintro/LICENSE.txt new file mode 100644 index 0000000000..94a9ed024d --- /dev/null +++ b/service.plexskipintro/LICENSE.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + 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/service.plexskipintro/README.md b/service.plexskipintro/README.md new file mode 100644 index 0000000000..0c96db89f8 --- /dev/null +++ b/service.plexskipintro/README.md @@ -0,0 +1,23 @@ +# KodiPlexSkipIntro + +Kodi plugin that uses plex to get intro start/end times and provides a button to skip intros + +## Name +KodiPlexSkipIntro + +[![.](https://github.com/Darkmadda/PlexSkipIntro/blob/main/resources/media/plexskipintroSS.png?raw=true)](#) +## Description +This add-on will display a button on screen when an tv intro starts allowing you to skip the intro. The button will display for 10 seconds (this period can be changed in the settings) + +## Installation +Download repository as zip file, then install add-on from zip file + +## Usage +Wait for intro to start and click the skip intro if desired or wait for the timeout to pass and the button will display. + +You will need to get a plex auth token and the base url for plex. look [here](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) to find out how to obtain this (the screenshot will help) + +[![.](https://github.com/Darkmadda/PlexSkipIntro/blob/main/resources/media/plexskipintroTokenSS.png?raw=true)](#) + +## License +GNU GENERAL PUBLIC LICENSE diff --git a/service.plexskipintro/addon.xml b/service.plexskipintro/addon.xml new file mode 100644 index 0000000000..64d73746ae --- /dev/null +++ b/service.plexskipintro/addon.xml @@ -0,0 +1,22 @@ + + + + + + + + + Prompt a Skip Intro dialog Netflix style using plex metadata. Skips intro for your favourite shows + Skip TV Shows Intro + all + -https://forum.kodi.tv/showthread.php?tid=368916 + https://github.com/Darkmadda/PlexSkipIntro + https://github.com/Darkmadda/PlexSkipIntro + https://github.com/Darkmadda/PlexSkipIntro/releases + GPL-3.0-ONLY + + resources/icon.png + resources/fanart.jpg + + + diff --git a/service.plexskipintro/lib/__init__.py b/service.plexskipintro/lib/__init__.py new file mode 100644 index 0000000000..f7904b00c7 --- /dev/null +++ b/service.plexskipintro/lib/__init__.py @@ -0,0 +1 @@ +from lib.addon import * diff --git a/service.plexskipintro/lib/addon.py b/service.plexskipintro/lib/addon.py new file mode 100644 index 0000000000..e01313b28d --- /dev/null +++ b/service.plexskipintro/lib/addon.py @@ -0,0 +1,107 @@ +import xbmc, xbmcaddon, xbmcgui +from threading import Timer +from plexapi.server import PlexServer +from lib.definitions import * +import pprint +import time + +def closeDialog(): + global Dialog + global timer + global running + global Ran + global default_timeout + Dialog.close() + timer.cancel() + Ran = True + running = False + timer = Timer(default_timeout, closeDialog) + +def onPlay(): + xbmc.log("PLAY========***************",xbmc.LOGINFO) + global Ran + global introFound + global introStartTime + global introEndTime + Ran = False + introFound = False + myPlayer = xbmc.Player() # make Player() a single call. + while not myPlayer.isPlayingVideo(): + time.sleep(1) + if myPlayer.isPlayingVideo(): + season_number = myPlayer.getVideoInfoTag().getSeason() + episode_number = myPlayer.getVideoInfoTag().getEpisode() + show = myPlayer.getVideoInfoTag().getTVShowTitle() + baseurl = xbmcaddon.Addon().getSettingString("plex_base_url") + token = xbmcaddon.Addon().getSettingString("auth_token") + plex = PlexServer(baseurl, token) + shows = plex.library.section('TV Shows') + show = shows.search(show)[0] + episode = show.episode(None, season_number, episode_number) + for marker in episode.markers: + if (marker.type == "intro"): + introFound = True + introStartTime = marker.start / 1000 + introEndTime = marker.end / 1000 + +def monitor(): + monitor = xbmc.Monitor() + global introFound + global introStartTime + global introEndTime + global Ran + global Dialog + global running + global timer + global default_timeout + Dialog = CustomDialog('script-dialog.xml', addonPath) + while not monitor.abortRequested(): + # check every 5 sec + if monitor.waitForAbort(3): + # Abort was requested while waiting. We should exit + break + + if xbmc.Player().isPlaying(): + if introFound: + if xbmc.Player().getTime() > introStartTime and xbmc.Player().getTime() < introEndTime: + if not running and not Ran: + timeout = introEndTime - xbmc.Player().getTime() + default_timeout + if timeout > default_timeout: + timeout = default_timeout + timer = Timer(timeout, closeDialog) + timer.start() + Dialog.show() + running = True + +def onSeek(): + global Ran + Ran = False + +timer = Timer(default_timeout, closeDialog) + +class CustomDialog(xbmcgui.WindowXMLDialog): + + def __init__(self, xmlFile, resourcePath): + None + + def onInit(self): + instuction = '' + + def onAction(self, action): + if action == ACTION_PREVIOUS_MENU or action == ACTION_BACK: + self.close() + + def onControl(self, control): + pass + + def onFocus(self, control): + pass + + def onClick(self, control): + global introEndTime + if control == OK_BUTTON: + xbmc.Player().seekTime(int(introEndTime)) + + if control in [OK_BUTTON, NEW_BUTTON, DISABLE_BUTTON]: + self.close() diff --git a/service.plexskipintro/lib/definitions.py b/service.plexskipintro/lib/definitions.py new file mode 100644 index 0000000000..0037c50ac2 --- /dev/null +++ b/service.plexskipintro/lib/definitions.py @@ -0,0 +1,18 @@ +import xbmc, xbmcaddon, xbmcvfs +OK_BUTTON = 201 +NEW_BUTTON = 202 +DISABLE_BUTTON = 210 +ACTION_PREVIOUS_MENU = 10 +ACTION_BACK = 92 +KODI_VERSION = int(xbmc.getInfoLabel("System.BuildVersion").split(".")[0]) +addonInfo = xbmcaddon.Addon().getAddonInfo +settings = xbmcaddon.Addon().getSetting +addonPath = xbmcvfs.translatePath(addonInfo('path')) +introFound = True +introStartTime = 0 +introEndTime = 0 +chosen = False +Dialog = None +running = False +Ran = False +default_timeout = xbmcaddon.Addon().getSettingInt("default_timeout") \ No newline at end of file diff --git a/service.plexskipintro/plexapi/__init__.py b/service.plexskipintro/plexapi/__init__.py new file mode 100644 index 0000000000..06a1ee63ca --- /dev/null +++ b/service.plexskipintro/plexapi/__init__.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +import logging +import os +from logging.handlers import RotatingFileHandler +from platform import uname +from uuid import getnode + +from plexapi.config import PlexConfig, reset_base_headers +import plexapi.const as const +from plexapi.utils import SecretsFilter + +# Load User Defined Config +DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini') +CONFIG_PATH = os.environ.get('PLEXAPI_CONFIG_PATH', DEFAULT_CONFIG_PATH) +CONFIG = PlexConfig(CONFIG_PATH) + +# PlexAPI Settings +PROJECT = 'PlexAPI' +VERSION = __version__ = const.__version__ +TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) +X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int) +X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool) + +# Plex Header Configuation +X_PLEX_PROVIDES = CONFIG.get('header.provides', 'controller') +X_PLEX_PLATFORM = CONFIG.get('header.platform', CONFIG.get('header.platorm', uname()[0])) +X_PLEX_PLATFORM_VERSION = CONFIG.get('header.platform_version', uname()[2]) +X_PLEX_PRODUCT = CONFIG.get('header.product', PROJECT) +X_PLEX_VERSION = CONFIG.get('header.version', VERSION) +X_PLEX_DEVICE = CONFIG.get('header.device', X_PLEX_PLATFORM) +X_PLEX_DEVICE_NAME = CONFIG.get('header.device_name', uname()[1]) +X_PLEX_IDENTIFIER = CONFIG.get('header.identifier', str(hex(getnode()))) +BASE_HEADERS = reset_base_headers() + +# Logging Configuration +log = logging.getLogger('plexapi') +logfile = CONFIG.get('log.path') +logformat = CONFIG.get('log.format', '%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s') +loglevel = CONFIG.get('log.level', 'INFO').upper() +loghandler = logging.NullHandler() + +if logfile: # pragma: no cover + logbackups = CONFIG.get('log.backup_count', 3, int) + logbytes = CONFIG.get('log.rotate_bytes', 512000, int) + loghandler = RotatingFileHandler(os.path.expanduser(logfile), 'a', logbytes, logbackups) + +loghandler.setFormatter(logging.Formatter(logformat)) +log.addHandler(loghandler) +log.setLevel(loglevel) +logfilter = SecretsFilter() +if CONFIG.get('log.show_secrets', '').lower() != 'true': + log.addFilter(logfilter) diff --git a/service.plexskipintro/plexapi/alert.py b/service.plexskipintro/plexapi/alert.py new file mode 100644 index 0000000000..79ecc4453c --- /dev/null +++ b/service.plexskipintro/plexapi/alert.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +import json +import threading + +from plexapi import log + + +class AlertListener(threading.Thread): + """ Creates a websocket connection to the PlexServer to optionally receive alert notifications. + These often include messages from Plex about media scans as well as updates to currently running + Transcode Sessions. This class implements threading.Thread, therefore to start monitoring + alerts you must call .start() on the object once it's created. When calling + `PlexServer.startAlertListener()`, the thread will be started for you. + + Known `state`-values for timeline entries, with identifier=`com.plexapp.plugins.library`: + + :0: The item was created + :1: Reporting progress on item processing + :2: Matching the item + :3: Downloading the metadata + :4: Processing downloaded metadata + :5: The item processed + :9: The item deleted + + When metadata agent is not set for the library processing ends with state=1. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to. + callback (func): Callback function to call on received messages. The callback function + will be sent a single argument 'data' which will contain a dictionary of data + received from the server. :samp:`def my_callback(data): ...` + callbackError (func): Callback function to call on errors. The callback function + will be sent a single argument 'error' which will contain the Error object. + :samp:`def my_callback(error): ...` + """ + key = '/:/websockets/notifications' + + def __init__(self, server, callback=None, callbackError=None): + super(AlertListener, self).__init__() + self.daemon = True + self._server = server + self._callback = callback + self._callbackError = callbackError + self._ws = None + + def run(self): + try: + import websocket + except ImportError: + log.warning("Can't use the AlertListener without websocket") + return + # create the websocket connection + url = self._server.url(self.key, includeToken=True).replace('http', 'ws') + log.info('Starting AlertListener: %s', url) + self._ws = websocket.WebSocketApp(url, on_message=self._onMessage, + on_error=self._onError) + self._ws.run_forever() + + def stop(self): + """ Stop the AlertListener thread. Once the notifier is stopped, it cannot be directly + started again. You must call :func:`~plexapi.server.PlexServer.startAlertListener` + from a PlexServer instance. + """ + log.info('Stopping AlertListener.') + self._ws.close() + + def _onMessage(self, *args): + """ Called when websocket message is received. + In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp + object and the message as a STR. Current releases appear to only return the message. + We are assuming the last argument in the tuple is the message. + This is to support compatibility with current and previous releases of websocket-client. + """ + message = args[-1] + try: + data = json.loads(message)['NotificationContainer'] + log.debug('Alert: %s %s %s', *data) + if self._callback: + self._callback(data) + except Exception as err: # pragma: no cover + log.error('AlertListener Msg Error: %s', err) + + def _onError(self, *args): # pragma: no cover + """ Called when websocket error is received. + In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp + object and the error. Current releases appear to only return the error. + We are assuming the last argument in the tuple is the message. + This is to support compatibility with current and previous releases of websocket-client. + """ + err = args[-1] + try: + log.error('AlertListener Error: %s', err) + if self._callbackError: + self._callbackError(err) + except Exception as err: # pragma: no cover + log.error('AlertListener Error: Error: %s', err) diff --git a/service.plexskipintro/plexapi/audio.py b/service.plexskipintro/plexapi/audio.py new file mode 100644 index 0000000000..656b9250ac --- /dev/null +++ b/service.plexskipintro/plexapi/audio.py @@ -0,0 +1,427 @@ +# -*- coding: utf-8 -*- +import os +from urllib.parse import quote_plus + +from plexapi import library, media, utils +from plexapi.base import Playable, PlexPartialObject +from plexapi.exceptions import BadRequest +from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin +from plexapi.mixins import RatingMixin, SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin + + +class Audio(PlexPartialObject): + """ Base class for all audio objects including :class:`~plexapi.audio.Artist`, + :class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`. + + Attributes: + addedAt (datetime): Datetime the item was added to the library. + art (str): URL to artwork image (/library/metadata//art/). + artBlurHash (str): BlurHash string for artwork image. + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c). + index (int): Plex index number (often the track number). + key (str): API URL (/library/metadata/). + lastRatedAt (datetime): Datetime the item was last rated. + lastViewedAt (datetime): Datetime the item was last played. + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + listType (str): Hardcoded as 'audio' (useful for search filters). + moods (List<:class:`~plexapi.media.Mood`>): List of mood objects. + musicAnalysisVersion (int): The Plex music analysis version for the item. + ratingKey (int): Unique key identifying the item. + summary (str): Summary of the artist, album, or track. + thumb (str): URL to thumbnail image (/library/metadata//thumb/). + thumbBlurHash (str): BlurHash string for thumbnail image. + title (str): Name of the artist, album, or track (Jason Mraz, We Sing, Lucky, etc.). + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'artist', 'album', or 'track'. + updatedAt (datatime): Datetime the item was updated. + userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars). + viewCount (int): Count of times the item was played. + """ + + METADATA_TYPE = 'track' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.art = data.attrib.get('art') + self.artBlurHash = data.attrib.get('artBlurHash') + self.fields = self.findItems(data, media.Field) + self.guid = data.attrib.get('guid') + self.index = utils.cast(int, data.attrib.get('index')) + self.key = data.attrib.get('key', '') + self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) + self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.listType = 'audio' + self.moods = self.findItems(data, media.Mood) + self.musicAnalysisVersion = utils.cast(int, data.attrib.get('musicAnalysisVersion')) + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.summary = data.attrib.get('summary') + self.thumb = data.attrib.get('thumb') + self.thumbBlurHash = data.attrib.get('thumbBlurHash') + self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort', self.title) + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.userRating = utils.cast(float, data.attrib.get('userRating')) + self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) + + def url(self, part): + """ Returns the full URL for the audio item. Typically used for getting a specific track. """ + return self._server.url(part, includeToken=True) if part else None + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return self.title + + @property + def hasSonicAnalysis(self): + """ Returns True if the audio has been sonically analyzed. """ + return self.musicAnalysisVersion == 1 + + def sync(self, bitrate, client=None, clientId=None, limit=None, title=None): + """ Add current audio (artist, album or track) as sync item for specified device. + See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. + + Parameters: + bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the + module :mod:`~plexapi.sync`. + client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`~plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`. + limit (int): maximum count of items to sync, unlimited if `None`. + title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current media. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + """ + + from plexapi.sync import SyncItem, Policy, MediaSettings + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self._defaultSyncTitle() + sync_item.rootTitle = self.title + sync_item.contentType = self.listType + sync_item.metadataType = self.METADATA_TYPE + sync_item.machineIdentifier = self._server.machineIdentifier + + section = self._server.library.sectionByID(self.librarySectionID) + + sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key)) + sync_item.policy = Policy.create(limit) + sync_item.mediaSettings = MediaSettings.createMusic(bitrate) + + return myplex.sync(sync_item, client=client, clientId=clientId) + + +@utils.registerPlexObject +class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin, + CollectionMixin, CountryMixin, GenreMixin, MoodMixin, SimilarArtistMixin, StyleMixin): + """ Represents a single Artist. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'artist' + albumSort (int): Setting that indicates how albums are sorted for the artist + (-1 = Library default, 0 = Newest first, 1 = Oldest first, 2 = By name). + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + countries (List<:class:`~plexapi.media.Country`>): List country objects. + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + key (str): API URL (/library/metadata/). + locations (List): List of folder paths where the artist is found on disk. + similar (List<:class:`~plexapi.media.Similar`>): List of similar objects. + styles (List<:class:`~plexapi.media.Style`>): List of style objects. + """ + TAG = 'Directory' + TYPE = 'artist' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Audio._loadData(self, data) + self.albumSort = utils.cast(int, data.attrib.get('albumSort', '-1')) + self.collections = self.findItems(data, media.Collection) + self.countries = self.findItems(data, media.Country) + self.genres = self.findItems(data, media.Genre) + self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.locations = self.listAttrs(data, 'path', etag='Location') + self.similar = self.findItems(data, media.Similar) + self.styles = self.findItems(data, media.Style) + + def __iter__(self): + for album in self.albums(): + yield album + + def hubs(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. """ + data = self._server.query(self._details_key) + return self.findItems(data, library.Hub, rtag='Related') + + def album(self, title): + """ Returns the :class:`~plexapi.audio.Album` that matches the specified title. + + Parameters: + title (str): Title of the album to return. + """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItem(key, Album, title__iexact=title) + + def albums(self, **kwargs): + """ Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, Album, **kwargs) + + def track(self, title=None, album=None, track=None): + """ Returns the :class:`~plexapi.audio.Track` that matches the specified title. + + Parameters: + title (str): Title of the track to return. + album (str): Album name (default: None; required if title not specified). + track (int): Track number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing. + """ + key = '/library/metadata/%s/allLeaves' % self.ratingKey + if title is not None: + return self.fetchItem(key, Track, title__iexact=title) + elif album is not None and track is not None: + return self.fetchItem(key, Track, parentTitle__iexact=album, index=track) + raise BadRequest('Missing argument: title or album and track are required') + + def tracks(self, **kwargs): + """ Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """ + key = '/library/metadata/%s/allLeaves' % self.ratingKey + return self.fetchItems(key, Track, **kwargs) + + def get(self, title=None, album=None, track=None): + """ Alias of :func:`~plexapi.audio.Artist.track`. """ + return self.track(title, album, track) + + def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs): + """ Download all tracks from the artist. See :func:`~plexapi.base.Playable.download` for details. + + Parameters: + savepath (str): Defaults to current working dir. + keep_original_name (bool): True to keep the original filename otherwise + a friendlier filename is generated. + subfolders (bool): True to separate tracks in to album folders. + **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`. + """ + filepaths = [] + for track in self.tracks(): + _savepath = os.path.join(savepath, track.parentTitle) if subfolders else savepath + filepaths += track.download(_savepath, keep_original_name, **kwargs) + return filepaths + + +@utils.registerPlexObject +class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, + CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin): + """ Represents a single Album. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'album' + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + formats (List<:class:`~plexapi.media.Format`>): List of format objects. + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + key (str): API URL (/library/metadata/). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + leafCount (int): Number of items in the album view. + loudnessAnalysisVersion (int): The Plex loudness analysis version level. + originallyAvailableAt (datetime): Datetime the album was released. + parentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). + parentKey (str): API URL of the album artist (/library/metadata/). + parentRatingKey (int): Unique key identifying the album artist. + parentThumb (str): URL to album artist thumbnail image (/library/metadata//thumb/). + parentTitle (str): Name of the album artist. + rating (float): Album rating (7.9; 9.8; 8.1). + studio (str): Studio that released the album. + styles (List<:class:`~plexapi.media.Style`>): List of style objects. + subformats (List<:class:`~plexapi.media.Subformat`>): List of subformat objects. + viewedLeafCount (int): Number of items marked as played in the album view. + year (int): Year the album was released. + """ + TAG = 'Directory' + TYPE = 'album' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Audio._loadData(self, data) + self.collections = self.findItems(data, media.Collection) + self.formats = self.findItems(data, media.Format) + self.genres = self.findItems(data, media.Genre) + self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.labels = self.findItems(data, media.Label) + self.leafCount = utils.cast(int, data.attrib.get('leafCount')) + self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion')) + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.parentGuid = data.attrib.get('parentGuid') + self.parentKey = data.attrib.get('parentKey') + self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentThumb = data.attrib.get('parentThumb') + self.parentTitle = data.attrib.get('parentTitle') + self.rating = utils.cast(float, data.attrib.get('rating')) + self.studio = data.attrib.get('studio') + self.styles = self.findItems(data, media.Style) + self.subformats = self.findItems(data, media.Subformat) + self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) + self.year = utils.cast(int, data.attrib.get('year')) + + def __iter__(self): + for track in self.tracks(): + yield track + + def track(self, title=None, track=None): + """ Returns the :class:`~plexapi.audio.Track` that matches the specified title. + + Parameters: + title (str): Title of the track to return. + track (int): Track number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing. + """ + key = '/library/metadata/%s/children' % self.ratingKey + if title is not None: + return self.fetchItem(key, Track, title__iexact=title) + elif track is not None: + return self.fetchItem(key, Track, parentTitle__iexact=self.title, index=track) + raise BadRequest('Missing argument: title or track is required') + + def tracks(self, **kwargs): + """ Returns a list of :class:`~plexapi.audio.Track` objects in the album. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, Track, **kwargs) + + def get(self, title=None, track=None): + """ Alias of :func:`~plexapi.audio.Album.track`. """ + return self.track(title, track) + + def artist(self): + """ Return the album's :class:`~plexapi.audio.Artist`. """ + return self.fetchItem(self.parentKey) + + def download(self, savepath=None, keep_original_name=False, **kwargs): + """ Download all tracks from the album. See :func:`~plexapi.base.Playable.download` for details. + + Parameters: + savepath (str): Defaults to current working dir. + keep_original_name (bool): True to keep the original filename otherwise + a friendlier filename is generated. + **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`. + """ + filepaths = [] + for track in self.tracks(): + filepaths += track.download(savepath, keep_original_name, **kwargs) + return filepaths + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return '%s - %s' % (self.parentTitle, self.title) + + +@utils.registerPlexObject +class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, + CollectionMixin, MoodMixin): + """ Represents a single Track. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'track' + chapterSource (str): Unknown + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + duration (int): Length of the track in milliseconds. + grandparentArt (str): URL to album artist artwork (/library/metadata//art/). + grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). + grandparentKey (str): API URL of the album artist (/library/metadata/). + grandparentRatingKey (int): Unique key identifying the album artist. + grandparentThumb (str): URL to album artist thumbnail image + (/library/metadata//thumb/). + grandparentTitle (str): Name of the album artist for the track. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originalTitle (str): The artist for the track. + parentGuid (str): Plex GUID for the album (plex://album/5d07cd8e403c640290f180f9). + parentIndex (int): Album index. + parentKey (str): API URL of the album (/library/metadata/). + parentRatingKey (int): Unique key identifying the album. + parentThumb (str): URL to album thumbnail image (/library/metadata//thumb/). + parentTitle (str): Name of the album for the track. + primaryExtraKey (str) API URL for the primary extra for the track. + ratingCount (int): Number of ratings contributing to the rating score. + viewOffset (int): View offset in milliseconds. + year (int): Year the track was released. + """ + TAG = 'Track' + TYPE = 'track' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Audio._loadData(self, data) + Playable._loadData(self, data) + self.chapterSource = data.attrib.get('chapterSource') + self.collections = self.findItems(data, media.Collection) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.grandparentArt = data.attrib.get('grandparentArt') + self.grandparentGuid = data.attrib.get('grandparentGuid') + self.grandparentKey = data.attrib.get('grandparentKey') + self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) + self.grandparentThumb = data.attrib.get('grandparentThumb') + self.grandparentTitle = data.attrib.get('grandparentTitle') + self.media = self.findItems(data, media.Media) + self.originalTitle = data.attrib.get('originalTitle') + self.parentGuid = data.attrib.get('parentGuid') + self.parentIndex = data.attrib.get('parentIndex') + self.parentKey = data.attrib.get('parentKey') + self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentThumb = data.attrib.get('parentThumb') + self.parentTitle = data.attrib.get('parentTitle') + self.primaryExtraKey = data.attrib.get('primaryExtraKey') + self.ratingCount = utils.cast(int, data.attrib.get('ratingCount')) + self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) + self.year = utils.cast(int, data.attrib.get('year')) + + def _prettyfilename(self): + """ Returns a filename for use in download. """ + return '%s - %s - %s - %s' % ( + self.grandparentTitle, self.parentTitle, str(self.trackNumber).zfill(2), self.title) + + def album(self): + """ Return the track's :class:`~plexapi.audio.Album`. """ + return self.fetchItem(self.parentKey) + + def artist(self): + """ Return the track's :class:`~plexapi.audio.Artist`. """ + return self.fetchItem(self.grandparentKey) + + @property + def locations(self): + """ This does not exist in plex xml response but is added to have a common + interface to get the locations of the track. + + Returns: + List of file paths where the track is found on disk. + """ + return [part.file for part in self.iterParts() if part] + + @property + def trackNumber(self): + """ Returns the track number. """ + return self.index + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) + + def _getWebURL(self, base=None): + """ Get the Plex Web URL with the correct parameters. """ + return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey) diff --git a/service.plexskipintro/plexapi/base.py b/service.plexskipintro/plexapi/base.py new file mode 100644 index 0000000000..ab3c2e64a8 --- /dev/null +++ b/service.plexskipintro/plexapi/base.py @@ -0,0 +1,798 @@ +# -*- coding: utf-8 -*- +import re +import weakref +from urllib.parse import quote_plus, urlencode +from xml.etree import ElementTree + +from plexapi import log, utils +from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported + +USER_DONT_RELOAD_FOR_KEYS = set() +_DONT_RELOAD_FOR_KEYS = {'key', 'session'} +_DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'} +OPERATORS = { + 'exact': lambda v, q: v == q, + 'iexact': lambda v, q: v.lower() == q.lower(), + 'contains': lambda v, q: q in v, + 'icontains': lambda v, q: q.lower() in v.lower(), + 'ne': lambda v, q: v != q, + 'in': lambda v, q: v in q, + 'gt': lambda v, q: v > q, + 'gte': lambda v, q: v >= q, + 'lt': lambda v, q: v < q, + 'lte': lambda v, q: v <= q, + 'startswith': lambda v, q: v.startswith(q), + 'istartswith': lambda v, q: v.lower().startswith(q), + 'endswith': lambda v, q: v.endswith(q), + 'iendswith': lambda v, q: v.lower().endswith(q), + 'exists': lambda v, q: v is not None if q else v is None, + 'regex': lambda v, q: re.match(q, v), + 'iregex': lambda v, q: re.match(q, v, flags=re.IGNORECASE), +} + + +class PlexObject(object): + """ Base class for all Plex objects. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional) + data (ElementTree): Response from PlexServer used to build this object (optional). + initpath (str): Relative path requested when retrieving specified `data` (optional). + parent (:class:`~plexapi.base.PlexObject`): The parent object that this object is built from (optional). + """ + TAG = None # xml element tag + TYPE = None # xml element type + key = None # plex relative url + + def __init__(self, server, data, initpath=None, parent=None): + self._server = server + self._data = data + self._initpath = initpath or self.key + self._parent = weakref.ref(parent) if parent is not None else None + self._details_key = None + if data is not None: + self._loadData(data) + self._details_key = self._buildDetailsKey() + self._autoReload = False + + def __repr__(self): + uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri')) + name = self._clean(self.firstAttr('title', 'name', 'username', 'product', 'tag', 'value')) + return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p]) + + def __setattr__(self, attr, value): + # Don't overwrite session specific attr with [] + if attr in _DONT_OVERWRITE_SESSION_KEYS and value == []: + value = getattr(self, attr, []) + + autoReload = self.__dict__.get('_autoReload') + # Don't overwrite an attr with None unless it's a private variable or not auto reload + if value is not None or attr.startswith('_') or attr not in self.__dict__ or not autoReload: + self.__dict__[attr] = value + + def _clean(self, value): + """ Clean attr value for display in __repr__. """ + if value: + value = str(value).replace('/library/metadata/', '') + value = value.replace('/children', '') + value = value.replace('/accounts/', '') + value = value.replace('/devices/', '') + return value.replace(' ', '-')[:20] + + def _buildItem(self, elem, cls=None, initpath=None): + """ Factory function to build objects based on registered PLEXOBJECTS. """ + # cls is specified, build the object and return + initpath = initpath or self._initpath + if cls is not None: + return cls(self._server, elem, initpath, parent=self) + # cls is not specified, try looking it up in PLEXOBJECTS + etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type'))) + ehash = '%s.%s' % (elem.tag, etype) if etype else elem.tag + ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag)) + # log.debug('Building %s as %s', elem.tag, ecls.__name__) + if ecls is not None: + return ecls(self._server, elem, initpath) + raise UnknownType("Unknown library type <%s type='%s'../>" % (elem.tag, etype)) + + def _buildItemOrNone(self, elem, cls=None, initpath=None): + """ Calls :func:`~plexapi.base.PlexObject._buildItem` but returns + None if elem is an unknown type. + """ + try: + return self._buildItem(elem, cls, initpath) + except UnknownType: + return None + + def _buildDetailsKey(self, **kwargs): + """ Builds the details key with the XML include parameters. + All parameters are included by default with the option to override each parameter + or disable each parameter individually by setting it to False or 0. + """ + details_key = self.key + if details_key and hasattr(self, '_INCLUDES'): + includes = {} + for k, v in list(self._INCLUDES.items()): + value = kwargs.get(k, v) + if value not in [False, 0, '0']: + includes[k] = 1 if value is True else value + if includes: + details_key += '?' + urlencode(sorted(includes.items())) + return details_key + + def _isChildOf(self, **kwargs): + """ Returns True if this object is a child of the given attributes. + This will search the parent objects all the way to the top. + + Parameters: + **kwargs (dict): The attributes and values to search for in the parent objects. + See all possible `**kwargs*` in :func:`~plexapi.base.PlexObject.fetchItem`. + """ + obj = self + while obj and obj._parent is not None: + obj = obj._parent() + if obj and obj._checkAttrs(obj._data, **kwargs): + return True + return False + + def _manuallyLoadXML(self, xml, cls=None): + """ Manually load an XML string as a :class:`~plexapi.base.PlexObject`. + + Parameters: + xml (str): The XML string to load. + cls (:class:`~plexapi.base.PlexObject`): If you know the class of the + items to be fetched, passing this in will help the parser ensure + it only returns those items. By default we convert the xml elements + with the best guess PlexObjects based on tag and type attrs. + """ + elem = ElementTree.fromstring(xml) + return self._buildItemOrNone(elem, cls) + + def fetchItem(self, ekey, cls=None, **kwargs): + """ Load the specified key to find and build the first item with the + specified tag and attrs. If no tag or attrs are specified then + the first item in the result set is returned. + + Parameters: + ekey (str or int): Path in Plex to fetch items from. If an int is passed + in, the key will be translated to /library/metadata/. This allows + fetching an item only knowing its key-id. + cls (:class:`~plexapi.base.PlexObject`): If you know the class of the + items to be fetched, passing this in will help the parser ensure + it only returns those items. By default we convert the xml elements + with the best guess PlexObjects based on tag and type attrs. + etag (str): Only fetch items with the specified tag. + **kwargs (dict): Optionally add XML attribute to filter the items. + See :func:`~plexapi.base.PlexObject.fetchItems` for more details + on how this is used. + """ + if ekey is None: + raise BadRequest('ekey was not provided') + if isinstance(ekey, int): + ekey = '/library/metadata/%s' % ekey + for elem in self._server.query(ekey): + if self._checkAttrs(elem, **kwargs): + return self._buildItem(elem, cls, ekey) + clsname = cls.__name__ if cls else 'None' + raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs)) + + def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs): + """ Load the specified key to find and build all items with the specified tag + and attrs. + + Parameters: + ekey (str): API URL path in Plex to fetch items from. + cls (:class:`~plexapi.base.PlexObject`): If you know the class of the + items to be fetched, passing this in will help the parser ensure + it only returns those items. By default we convert the xml elements + with the best guess PlexObjects based on tag and type attrs. + etag (str): Only fetch items with the specified tag. + container_start (None, int): offset to get a subset of the data + container_size (None, int): How many items in data + **kwargs (dict): Optionally add XML attribute to filter the items. + See the details below for more info. + + **Filtering XML Attributes** + + Any XML attribute can be filtered when fetching results. Filtering is done before + the Python objects are built to help keep things speedy. For example, passing in + ``viewCount=0`` will only return matching items where the view count is ``0``. + Note that case matters when specifying attributes. Attributes futher down in the XML + tree can be filtered by *prepending* the attribute with each element tag ``Tag__``. + + Examples: + + .. code-block:: python + + fetchItem(ekey, viewCount=0) + fetchItem(ekey, contentRating="PG") + fetchItem(ekey, Genre__tag="Animation") + fetchItem(ekey, Media__videoCodec="h265") + fetchItem(ekey, Media__Part__container="mp4) + + Note that because some attribute names are already used as arguments to this + function, such as ``tag``, you may still reference the attr tag by prepending an + underscore. For example, passing in ``_tag='foobar'`` will return all items where + ``tag='foobar'``. + + **Using PlexAPI Operators** + + Optionally, PlexAPI operators can be specified by *appending* it to the end of the + attribute for more complex lookups. For example, passing in ``viewCount__gte=0`` + will return all items where ``viewCount >= 0``. + + List of Available Operators: + + * ``__contains``: Value contains specified arg. + * ``__endswith``: Value ends with specified arg. + * ``__exact``: Value matches specified arg. + * ``__exists`` (*bool*): Value is or is not present in the attrs. + * ``__gt``: Value is greater than specified arg. + * ``__gte``: Value is greater than or equal to specified arg. + * ``__icontains``: Case insensative value contains specified arg. + * ``__iendswith``: Case insensative value ends with specified arg. + * ``__iexact``: Case insensative value matches specified arg. + * ``__in``: Value is in a specified list or tuple. + * ``__iregex``: Case insensative value matches the specified regular expression. + * ``__istartswith``: Case insensative value starts with specified arg. + * ``__lt``: Value is less than specified arg. + * ``__lte``: Value is less than or equal to specified arg. + * ``__regex``: Value matches the specified regular expression. + * ``__startswith``: Value starts with specified arg. + + Examples: + + .. code-block:: python + + fetchItem(ekey, viewCount__gte=0) + fetchItem(ekey, Media__container__in=["mp4", "mkv"]) + fetchItem(ekey, guid__iregex=r"(imdb:\/\/|themoviedb:\/\/)") + fetchItem(ekey, Media__Part__file__startswith="D:\\Movies") + + """ + url_kw = {} + if container_start is not None: + url_kw["X-Plex-Container-Start"] = container_start + if container_size is not None: + url_kw["X-Plex-Container-Size"] = container_size + + if ekey is None: + raise BadRequest('ekey was not provided') + data = self._server.query(ekey, params=url_kw) + items = self.findItems(data, cls, ekey, **kwargs) + + librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + if librarySectionID: + for item in items: + item.librarySectionID = librarySectionID + return items + + def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs): + """ Load the specified data to find and build all items with the specified tag + and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details + on how this is used. + """ + # filter on cls attrs if specified + if cls and cls.TAG and 'tag' not in kwargs: + kwargs['etag'] = cls.TAG + if cls and cls.TYPE and 'type' not in kwargs: + kwargs['type'] = cls.TYPE + # rtag to iter on a specific root tag + if rtag: + data = next(data.iter(rtag), []) + # loop through all data elements to find matches + items = [] + for elem in data: + if self._checkAttrs(elem, **kwargs): + item = self._buildItemOrNone(elem, cls, initpath) + if item is not None: + items.append(item) + return items + + def firstAttr(self, *attrs): + """ Return the first attribute in attrs that is not None. """ + for attr in attrs: + value = getattr(self, attr, None) + if value is not None: + return value + + def listAttrs(self, data, attr, rtag=None, **kwargs): + """ Return a list of values from matching attribute. """ + results = [] + # rtag to iter on a specific root tag + if rtag: + data = next(data.iter(rtag), []) + for elem in data: + kwargs['%s__exists' % attr] = True + if self._checkAttrs(elem, **kwargs): + results.append(elem.attrib.get(attr)) + return results + + def reload(self, key=None, **kwargs): + """ Reload the data for this object from self.key. + + Parameters: + key (string, optional): Override the key to reload. + **kwargs (dict): A dictionary of XML include parameters to exclude or override. + All parameters are included by default with the option to override each parameter + or disable each parameter individually by setting it to False or 0. + See :class:`~plexapi.base.PlexPartialObject` for all the available include parameters. + + Example: + + .. code-block:: python + + from plexapi.server import PlexServer + plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx') + movie = plex.library.section('Movies').get('Cars') + + # Partial reload of the movie without the `checkFiles` parameter. + # Excluding `checkFiles` will prevent the Plex server from reading the + # file to check if the file still exists and is accessible. + # The movie object will remain as a partial object. + movie.reload(checkFiles=False) + movie.isPartialObject() # Returns True + + # Full reload of the movie with all include parameters. + # The movie object will be a full object. + movie.reload() + movie.isFullObject() # Returns True + + """ + return self._reload(key=key, **kwargs) + + def _reload(self, key=None, _autoReload=False, **kwargs): + """ Perform the actual reload. """ + details_key = self._buildDetailsKey(**kwargs) if kwargs else self._details_key + key = key or details_key or self.key + if not key: + raise Unsupported('Cannot reload an object not built from a URL.') + self._initpath = key + data = self._server.query(key) + self._autoReload = _autoReload + self._loadData(data[0]) + self._autoReload = False + return self + + def _checkAttrs(self, elem, **kwargs): + attrsFound = {} + for attr, query in list(kwargs.items()): + attr, op, operator = self._getAttrOperator(attr) + values = self._getAttrValue(elem, attr) + # special case query in (None, 0, '') to include missing attr + if op == 'exact' and not values and query in (None, 0, ''): + return True + # return if attr were looking for is missing + attrsFound[attr] = False + for value in values: + value = self._castAttrValue(op, query, value) + if operator(value, query): + attrsFound[attr] = True + break + # log.debug('Checking %s for %s found: %s', elem.tag, kwargs, attrsFound) + return all(attrsFound.values()) + + def _getAttrOperator(self, attr): + for op, operator in list(OPERATORS.items()): + if attr.endswith('__%s' % op): + attr = attr.rsplit('__', 1)[0] + return attr, op, operator + # default to exact match + return attr, 'exact', OPERATORS['exact'] + + def _getAttrValue(self, elem, attrstr, results=None): + # log.debug('Fetching %s in %s', attrstr, elem.tag) + parts = attrstr.split('__', 1) + attr = parts[0] + attrstr = parts[1] if len(parts) == 2 else None + if attrstr: + results = [] if results is None else results + for child in [c for c in elem if c.tag.lower() == attr.lower()]: + results += self._getAttrValue(child, attrstr, results) + return [r for r in results if r is not None] + # check were looking for the tag + if attr.lower() == 'etag': + return [elem.tag] + # loop through attrs so we can perform case-insensative match + for _attr, value in list(elem.attrib.items()): + if attr.lower() == _attr.lower(): + return [value] + return [] + + def _castAttrValue(self, op, query, value): + if op == 'exists': + return value + if isinstance(query, bool): + return bool(int(value)) + if isinstance(query, int) and '.' in value: + return float(value) + if isinstance(query, int): + return int(value) + if isinstance(query, float): + return float(value) + return value + + def _loadData(self, data): + raise NotImplementedError('Abstract method not implemented.') + + +class PlexPartialObject(PlexObject): + """ Not all objects in the Plex listings return the complete list of elements + for the object. This object will allow you to assume each object is complete, + and if the specified value you request is None it will fetch the full object + automatically and update itself. + """ + _INCLUDES = { + 'checkFiles': 1, + 'includeAllConcerts': 1, + 'includeBandwidths': 1, + 'includeChapters': 1, + 'includeChildren': 1, + 'includeConcerts': 1, + 'includeExternalMedia': 1, + 'includeExtras': 1, + 'includeFields': 'thumbBlurHash,artBlurHash', + 'includeGeolocation': 1, + 'includeLoudnessRamps': 1, + 'includeMarkers': 1, + 'includeOnDeck': 1, + 'includePopularLeaves': 1, + 'includePreferences': 1, + 'includeRelated': 1, + 'includeRelatedCount': 1, + 'includeReviews': 1, + 'includeStations': 1 + } + + def __eq__(self, other): + return other not in [None, []] and self.key == other.key + + def __hash__(self): + return hash(repr(self)) + + def __iter__(self): + yield self + + def __getattribute__(self, attr): + # Dragons inside.. :-/ + value = super(PlexPartialObject, self).__getattribute__(attr) + # Check a few cases where we dont want to reload + if attr in _DONT_RELOAD_FOR_KEYS: return value + if attr in _DONT_OVERWRITE_SESSION_KEYS: return value + if attr in USER_DONT_RELOAD_FOR_KEYS: return value + if attr.startswith('_'): return value + if value not in (None, []): return value + if self.isFullObject(): return value + # Log the reload. + clsname = self.__class__.__name__ + title = self.__dict__.get('title', self.__dict__.get('name')) + objname = "%s '%s'" % (clsname, title) if title else clsname + log.debug("Reloading %s for attr '%s'", objname, attr) + # Reload and return the value + self._reload(_autoReload=True) + return super(PlexPartialObject, self).__getattribute__(attr) + + def analyze(self): + """ Tell Plex Media Server to performs analysis on it this item to gather + information. Analysis includes: + + * Gather Media Properties: All of the media you add to a Library has + properties that are useful to know–whether it's a video file, a + music track, or one of your photos (container, codec, resolution, etc). + * Generate Default Artwork: Artwork will automatically be grabbed from a + video file. A background image will be pulled out as well as a + smaller image to be used for poster/thumbnail type purposes. + * Generate Video Preview Thumbnails: Video preview thumbnails are created, + if you have that feature enabled. Video preview thumbnails allow + graphical seeking in some Apps. It's also used in the Plex Web App Now + Playing screen to show a graphical representation of where playback + is. Video preview thumbnails creation is a CPU-intensive process akin + to transcoding the file. + * Generate intro video markers: Detects show intros, exposing the + 'Skip Intro' button in clients. + """ + key = '/%s/analyze' % self.key.lstrip('/') + self._server.query(key, method=self._server._session.put) + + def isFullObject(self): + """ Returns True if this is already a full object. A full object means all attributes + were populated from the api path representing only this item. For example, the + search result for a movie often only contain a portion of the attributes a full + object (main url) for that movie would contain. + """ + return not self.key or (self._details_key or self.key) == self._initpath + + def isPartialObject(self): + """ Returns True if this is not a full object. """ + return not self.isFullObject() + + def _edit(self, **kwargs): + """ Actually edit an object. """ + if 'id' not in kwargs: + kwargs['id'] = self.ratingKey + if 'type' not in kwargs: + kwargs['type'] = utils.searchType(self.type) + + part = '/library/sections/%s/all?%s' % (self.librarySectionID, + urlencode(kwargs)) + self._server.query(part, method=self._server._session.put) + + def edit(self, **kwargs): + """ Edit an object. + + Parameters: + kwargs (dict): Dict of settings to edit. + + Example: + {'type': 1, + 'id': movie.ratingKey, + 'collection[0].tag.tag': 'Super', + 'collection.locked': 0} + """ + self._edit(**kwargs) + + def _edit_tags(self, tag, items, locked=True, remove=False): + """ Helper to edit tags. + + Parameters: + tag (str): Tag name. + items (list): List of tags to add. + locked (bool): True to lock the field. + remove (bool): True to remove the tags in items. + """ + if not isinstance(items, list): + items = [items] + value = getattr(self, utils.tag_plural(tag)) + existing_tags = [t.tag for t in value if t and remove is False] + tag_edits = utils.tag_helper(tag, existing_tags + items, locked, remove) + self.edit(**tag_edits) + + def refresh(self): + """ Refreshing a Library or individual item causes the metadata for the item to be + refreshed, even if it already has metadata. You can think of refreshing as + "update metadata for the requested item even if it already has some". You should + refresh a Library or individual item if: + + * You've changed the Library Metadata Agent. + * You've added "Local Media Assets" (such as artwork, theme music, external + subtitle files, etc.) + * You want to freshen the item posters, summary, etc. + * There's a problem with the poster image that's been downloaded. + * Items are missing posters or other downloaded information. This is possible if + the refresh process is interrupted (the Server is turned off, internet + connection dies, etc). + """ + key = '%s/refresh' % self.key + self._server.query(key, method=self._server._session.put) + + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """ + return self._server.library.sectionByID(self.librarySectionID) + + def delete(self): + """ Delete a media element. This has to be enabled under settings > server > library in plex webui. """ + try: + return self._server.query(self.key, method=self._server._session.delete) + except BadRequest: # pragma: no cover + log.error('Failed to delete %s. This could be because you ' + 'have not allowed items to be deleted', self.key) + raise + + def history(self, maxresults=9999999, mindate=None): + """ Get Play History for a media item. + + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey) + + def _getWebURL(self, base=None): + """ Get the Plex Web URL with the correct parameters. + Private method to allow overriding parameters from subclasses. + """ + return self._server._buildWebURL(base=base, endpoint='details', key=self.key) + + def getWebURL(self, base=None): + """ Returns the Plex Web URL for a media item. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + """ + return self._getWebURL(base=base) + + +class Playable(object): + """ This is a general place to store functions specific to media that is Playable. + Things were getting mixed up a bit when dealing with Shows, Season, Artists, + Albums which are all not playable. + + Attributes: + sessionKey (int): Active session key. + usernames (str): Username of the person playing this item (for active sessions). + players (:class:`~plexapi.client.PlexClient`): Client objects playing this item (for active sessions). + session (:class:`~plexapi.media.Session`): Session object, for a playing media file. + transcodeSessions (:class:`~plexapi.media.TranscodeSession`): Transcode Session object + if item is being transcoded (None otherwise). + viewedAt (datetime): Datetime item was last viewed (history). + accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. + deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID. + playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items). + playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items). + """ + + def _loadData(self, data): + self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) # session + self.usernames = self.listAttrs(data, 'title', etag='User') # session + self.players = self.findItems(data, etag='Player') # session + self.transcodeSessions = self.findItems(data, etag='TranscodeSession') # session + self.session = self.findItems(data, etag='Session') # session + self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history + self.accountID = utils.cast(int, data.attrib.get('accountID')) # history + self.deviceID = utils.cast(int, data.attrib.get('deviceID')) # history + self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist + self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue + + def getStreamURL(self, **params): + """ Returns a stream url that may be used by external applications such as VLC. + + Parameters: + **params (dict): optional parameters to manipulate the playback when accessing + the stream. A few known parameters include: maxVideoBitrate, videoResolution + offset, copyts, protocol, mediaIndex, platform. + + Raises: + :exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL. + """ + if self.TYPE not in ('movie', 'episode', 'track', 'clip'): + raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE) + mvb = params.get('maxVideoBitrate') + vr = params.get('videoResolution', '') + params = { + 'path': self.key, + 'offset': params.get('offset', 0), + 'copyts': params.get('copyts', 1), + 'protocol': params.get('protocol'), + 'mediaIndex': params.get('mediaIndex', 0), + 'X-Plex-Platform': params.get('platform', 'Chrome'), + 'maxVideoBitrate': max(mvb, 64) if mvb else None, + 'videoResolution': vr if re.match(r'^\d+x\d+$', vr) else None + } + # remove None values + params = {k: v for k, v in list(params.items()) if v is not None} + streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video' + # sort the keys since the randomness fucks with my tests.. + sorted_params = sorted(list(params.items()), key=lambda val: val[0]) + return self._server.url('/%s/:/transcode/universal/start.m3u8?%s' % + (streamtype, urlencode(sorted_params)), includeToken=True) + + def iterParts(self): + """ Iterates over the parts of this media item. """ + for item in self.media: + for part in item.parts: + yield part + + def play(self, client): + """ Start playback on the specified client. + + Parameters: + client (:class:`~plexapi.client.PlexClient`): Client to start playing on. + """ + client.playMedia(self) + + def download(self, savepath=None, keep_original_name=False, **kwargs): + """ Downloads the media item to the specified location. Returns a list of + filepaths that have been saved to disk. + + Parameters: + savepath (str): Defaults to current working dir. + keep_original_name (bool): True to keep the original filename otherwise + a friendlier filename is generated. See filenames below. + **kwargs (dict): Additional options passed into :func:`~plexapi.audio.Track.getStreamURL` + to download a transcoded stream, otherwise the media item will be downloaded + as-is and saved to disk. + + **Filenames** + + * Movie: `` (<year>)`` + * Episode: ``<show title> - s00e00 - <episode title>`` + * Track: ``<artist title> - <album title> - 00 - <track title>`` + * Photo: ``<photoalbum title> - <photo/clip title>`` or ``<photo/clip title>`` + """ + filepaths = [] + parts = [i for i in self.iterParts() if i] + + for part in parts: + if not keep_original_name: + filename = utils.cleanFilename('%s.%s' % (self._prettyfilename(), part.container)) + else: + filename = part.file + + if kwargs: + # So this seems to be a alot slower but allows transcode. + download_url = self.getStreamURL(**kwargs) + else: + download_url = self._server.url('%s?download=1' % part.key) + + filepath = utils.download( + download_url, + self._server._token, + filename=filename, + savepath=savepath, + session=self._server._session + ) + + if filepath: + filepaths.append(filepath) + + return filepaths + + def stop(self, reason=''): + """ Stop playback for a media item. """ + key = '/status/sessions/terminate?sessionId=%s&reason=%s' % (self.session[0].id, quote_plus(reason)) + return self._server.query(key) + + def updateProgress(self, time, state='stopped'): + """ Set the watched progress for this video. + + Note that setting the time to 0 will not work. + Use `markWatched` or `markUnwatched` to achieve + that goal. + + Parameters: + time (int): milliseconds watched + state (string): state of the video, default 'stopped' + """ + key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey, + time, state) + self._server.query(key) + self._reload(_autoReload=True) + + def updateTimeline(self, time, state='stopped', duration=None): + """ Set the timeline progress for this video. + + Parameters: + time (int): milliseconds watched + state (string): state of the video, default 'stopped' + duration (int): duration of the item + """ + durationStr = '&duration=' + if duration is not None: + durationStr = durationStr + str(duration) + else: + durationStr = durationStr + str(self.duration) + key = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s' + key %= (self.ratingKey, self.key, time, state, durationStr) + self._server.query(key) + self._reload(_autoReload=True) + + +class MediaContainer(PlexObject): + """ Represents a single MediaContainer. + + Attributes: + TAG (str): 'MediaContainer' + allowSync (int): Sync/Download is allowed/disallowed for feature. + augmentationKey (str): API URL (/library/metadata/augmentations/<augmentationKey>). + identifier (str): "com.plexapp.plugins.library" + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + librarySectionUUID (str): :class:`~plexapi.library.LibrarySection` UUID. + mediaTagPrefix (str): "/system/bundle/media/flags/" + mediaTagVersion (int): Unknown + size (int): The number of items in the hub. + + """ + TAG = 'MediaContainer' + + def _loadData(self, data): + self._data = data + self.allowSync = utils.cast(int, data.attrib.get('allowSync')) + self.augmentationKey = data.attrib.get('augmentationKey') + self.identifier = data.attrib.get('identifier') + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.librarySectionUUID = data.attrib.get('librarySectionUUID') + self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') + self.mediaTagVersion = data.attrib.get('mediaTagVersion') + self.size = utils.cast(int, data.attrib.get('size')) diff --git a/service.plexskipintro/plexapi/client.py b/service.plexskipintro/plexapi/client.py new file mode 100644 index 0000000000..56f522d222 --- /dev/null +++ b/service.plexskipintro/plexapi/client.py @@ -0,0 +1,632 @@ +# -*- coding: utf-8 -*- +import time +from xml.etree import ElementTree + +import requests +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils +from plexapi.base import PlexObject +from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported +from plexapi.playqueue import PlayQueue +from requests.status_codes import _codes as codes + +DEFAULT_MTYPE = 'video' + + +@utils.registerPlexObject +class PlexClient(PlexObject): + """ Main class for interacting with a Plex client. This class can connect + directly to the client and control it or proxy commands through your + Plex Server. To better understand the Plex client API's read this page: + https://github.com/plexinc/plex-media-player/wiki/Remote-control-API + + Parameters: + server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional). + data (ElementTree): Response from PlexServer used to build this object (optional). + initpath (str): Path used to generate data. + baseurl (str): HTTP URL to connect dirrectly to this client. + identifier (str): The resource/machine identifier for the desired client. + May be necessary when connecting to a specific proxied client (optional). + token (str): X-Plex-Token used for authenication (optional). + session (:class:`~requests.Session`): requests.Session object if you want more control (optional). + timeout (int): timeout in seconds on initial connect to client (default config.TIMEOUT). + + Attributes: + TAG (str): 'Player' + key (str): '/resources' + device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc). + deviceClass (str): Device class (pc, phone, etc). + machineIdentifier (str): Unique ID for this device. + model (str): Unknown + platform (str): Unknown + platformVersion (str): Description + product (str): Client Product (Plex for iOS, etc). + protocol (str): Always seems ot be 'plex'. + protocolCapabilities (list<str>): List of client capabilities (navigation, playback, + timeline, mirror, playqueues). + protocolVersion (str): Protocol version (1, future proofing?) + server (:class:`~plexapi.server.PlexServer`): Server this client is connected to. + session (:class:`~requests.Session`): Session object used for connection. + state (str): Unknown + title (str): Name of this client (Johns iPhone, etc). + token (str): X-Plex-Token used for authenication + vendor (str): Unknown + version (str): Device version (4.6.1, etc). + _baseurl (str): HTTP address of the client. + _token (str): Token used to access this client. + _session (obj): Requests session object used to access this client. + _proxyThroughServer (bool): Set to True after calling + :func:`~plexapi.client.PlexClient.proxyThroughServer` (default False). + """ + TAG = 'Player' + key = '/resources' + + def __init__(self, server=None, data=None, initpath=None, baseurl=None, + identifier=None, token=None, connect=True, session=None, timeout=None): + super(PlexClient, self).__init__(server, data, initpath) + self._baseurl = baseurl.strip('/') if baseurl else None + self._clientIdentifier = identifier + self._token = logfilter.add_secret(token) + self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' + server_session = server._session if server else None + self._session = session or server_session or requests.Session() + self._proxyThroughServer = False + self._commandId = 0 + self._last_call = 0 + self._timeline_cache = [] + self._timeline_cache_timestamp = 0 + if not any([data is not None, initpath, baseurl, token]): + self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433') + self._token = logfilter.add_secret(CONFIG.get('auth.client_token')) + if connect and self._baseurl: + self.connect(timeout=timeout) + + def _nextCommandId(self): + self._commandId += 1 + return self._commandId + + def connect(self, timeout=None): + """ Alias of reload as any subsequent requests to this client will be + made directly to the device even if the object attributes were initially + populated from a PlexServer. + """ + if not self.key: + raise Unsupported('Cannot reload an object not built from a URL.') + self._initpath = self.key + data = self.query(self.key, timeout=timeout) + if not data: + raise NotFound("Client not found at %s" % self._baseurl) + if self._clientIdentifier: + client = next( + ( + x + for x in data + if x.attrib.get("machineIdentifier") == self._clientIdentifier + ), + None, + ) + if client is None: + raise NotFound( + "Client with identifier %s not found at %s" + % (self._clientIdentifier, self._baseurl) + ) + else: + client = data[0] + self._loadData(client) + return self + + def reload(self): + """ Alias to self.connect(). """ + return self.connect() + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.deviceClass = data.attrib.get('deviceClass') + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.product = data.attrib.get('product') + self.protocol = data.attrib.get('protocol') + self.protocolCapabilities = data.attrib.get('protocolCapabilities', '').split(',') + self.protocolVersion = data.attrib.get('protocolVersion') + self.platform = data.attrib.get('platform') + self.platformVersion = data.attrib.get('platformVersion') + self.title = data.attrib.get('title') or data.attrib.get('name') + # Active session details + # Since protocolCapabilities is missing from /sessions we cant really control this player without + # creating a client manually. + # Add this in next breaking release. + # if self._initpath == 'status/sessions': + self.device = data.attrib.get('device') # session + self.model = data.attrib.get('model') # session + self.state = data.attrib.get('state') # session + self.vendor = data.attrib.get('vendor') # session + self.version = data.attrib.get('version') # session + self.local = utils.cast(bool, data.attrib.get('local', 0)) + self.address = data.attrib.get('address') # session + self.remotePublicAddress = data.attrib.get('remotePublicAddress') + self.userID = data.attrib.get('userID') + + def _headers(self, **kwargs): + """ Returns a dict of all default headers for Client requests. """ + headers = BASE_HEADERS + if self._token: + headers['X-Plex-Token'] = self._token + headers.update(kwargs) + return headers + + def proxyThroughServer(self, value=True, server=None): + """ Tells this PlexClient instance to proxy all future commands through the PlexServer. + Useful if you do not wish to connect directly to the Client device itself. + + Parameters: + value (bool): Enable or disable proxying (optional, default True). + + Raises: + :exc:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server. + """ + if server: + self._server = server + if value is True and not self._server: + raise Unsupported('Cannot use client proxy with unknown server.') + self._proxyThroughServer = value + + def query(self, path, method=None, headers=None, timeout=None, **kwargs): + """ Main method used to handle HTTPS requests to the Plex client. This method helps + by encoding the response to utf-8 and parsing the returned XML into and + ElementTree object. Returns None if no data exists in the response. + """ + url = self.url(path) + method = method or self._session.get + timeout = timeout or TIMEOUT + log.debug('%s %s', method.__name__.upper(), url) + headers = self._headers(**headers or {}) + response = method(url, headers=headers, timeout=timeout, **kwargs) + if response.status_code not in (200, 201, 204): + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) + if response.status_code == 401: + raise Unauthorized(message) + elif response.status_code == 404: + raise NotFound(message) + else: + raise BadRequest(message) + data = response.text.encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None + + def sendCommand(self, command, proxy=None, **params): + """ Convenience wrapper around :func:`~plexapi.client.PlexClient.query` to more easily + send simple commands to the client. Returns an ElementTree object containing + the response. + + Parameters: + command (str): Command to be sent in for format '<controller>/<command>'. + proxy (bool): Set True to proxy this command through the PlexServer. + **params (dict): Additional GET parameters to include with the command. + + Raises: + :exc:`~plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability. + """ + command = command.strip('/') + controller = command.split('/')[0] + headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier} + if controller not in self.protocolCapabilities: + log.debug('Client %s doesnt support %s controller.' + 'What your trying might not work' % (self.title, controller)) + + proxy = self._proxyThroughServer if proxy is None else proxy + query = self._server.query if proxy else self.query + + # Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244 + t = time.time() + if command == 'timeline/poll': + self._last_call = t + elif t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'): + self._last_call = t + self.sendCommand(ClientTimeline.key, wait=0) + + params['commandID'] = self._nextCommandId() + key = '/player/%s%s' % (command, utils.joinArgs(params)) + + try: + return query(key, headers=headers) + except ElementTree.ParseError: + # Workaround for players which don't return valid XML on successful commands + # - Plexamp, Plex for Android: `b'OK'` + # - Plex for Samsung: `b'<?xml version="1.0"?><Response code="200" status="OK">'` + if self.product in ( + 'Plexamp', + 'Plex for Android (TV)', + 'Plex for Android (Mobile)', + 'Plex for Samsung', + ): + return + raise + + def url(self, key, includeToken=False): + """ Build a URL string with proper token argument. Token will be appended to the URL + if either includeToken is True or CONFIG.log.show_secrets is 'true'. + """ + if not self._baseurl: + raise BadRequest('PlexClient object missing baseurl.') + if self._token and (includeToken or self._showSecrets): + delim = '&' if '?' in key else '?' + return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token) + return '%s%s' % (self._baseurl, key) + + # --------------------- + # Navigation Commands + # These commands navigate around the user-interface. + def contextMenu(self): + """ Open the context menu on the client. """ + self.sendCommand('navigation/contextMenu') + + def goBack(self): + """ Navigate back one position. """ + self.sendCommand('navigation/back') + + def goToHome(self): + """ Go directly to the home screen. """ + self.sendCommand('navigation/home') + + def goToMusic(self): + """ Go directly to the playing music panel. """ + self.sendCommand('navigation/music') + + def moveDown(self): + """ Move selection down a position. """ + self.sendCommand('navigation/moveDown') + + def moveLeft(self): + """ Move selection left a position. """ + self.sendCommand('navigation/moveLeft') + + def moveRight(self): + """ Move selection right a position. """ + self.sendCommand('navigation/moveRight') + + def moveUp(self): + """ Move selection up a position. """ + self.sendCommand('navigation/moveUp') + + def nextLetter(self): + """ Jump to next letter in the alphabet. """ + self.sendCommand('navigation/nextLetter') + + def pageDown(self): + """ Move selection down a full page. """ + self.sendCommand('navigation/pageDown') + + def pageUp(self): + """ Move selection up a full page. """ + self.sendCommand('navigation/pageUp') + + def previousLetter(self): + """ Jump to previous letter in the alphabet. """ + self.sendCommand('navigation/previousLetter') + + def select(self): + """ Select element at the current position. """ + self.sendCommand('navigation/select') + + def toggleOSD(self): + """ Toggle the on screen display during playback. """ + self.sendCommand('navigation/toggleOSD') + + def goToMedia(self, media, **params): + """ Navigate directly to the specified media page. + + Parameters: + media (:class:`~plexapi.media.Media`): Media object to navigate to. + **params (dict): Additional GET parameters to include with the command. + + Raises: + :exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. + """ + if not self._server: + raise Unsupported('A server must be specified before using this command.') + server_url = media._server._baseurl.split(':') + self.sendCommand('mirror/details', **dict({ + 'machineIdentifier': self._server.machineIdentifier, + 'address': server_url[1].strip('/'), + 'port': server_url[-1], + 'key': media.key, + 'protocol': server_url[0], + 'token': media._server.createToken() + }, **params)) + + # ------------------- + # Playback Commands + # Most of the playback commands take a mandatory mtype {'music','photo','video'} argument, + # to specify which media type to apply the command to, (except for playMedia). This + # is in case there are multiple things happening (e.g. music in the background, photo + # slideshow in the foreground). + def pause(self, mtype=DEFAULT_MTYPE): + """ Pause the currently playing media type. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/pause', type=mtype) + + def play(self, mtype=DEFAULT_MTYPE): + """ Start playback for the specified media type. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/play', type=mtype) + + def refreshPlayQueue(self, playQueueID, mtype=DEFAULT_MTYPE): + """ Refresh the specified Playqueue. + + Parameters: + playQueueID (str): Playqueue ID. + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand( + 'playback/refreshPlayQueue', playQueueID=playQueueID, type=mtype) + + def seekTo(self, offset, mtype=DEFAULT_MTYPE): + """ Seek to the specified offset (ms) during playback. + + Parameters: + offset (int): Position to seek to (milliseconds). + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/seekTo', offset=offset, type=mtype) + + def skipNext(self, mtype=DEFAULT_MTYPE): + """ Skip to the next playback item. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/skipNext', type=mtype) + + def skipPrevious(self, mtype=DEFAULT_MTYPE): + """ Skip to previous playback item. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/skipPrevious', type=mtype) + + def skipTo(self, key, mtype=DEFAULT_MTYPE): + """ Skip to the playback item with the specified key. + + Parameters: + key (str): Key of the media item to skip to. + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/skipTo', key=key, type=mtype) + + def stepBack(self, mtype=DEFAULT_MTYPE): + """ Step backward a chunk of time in the current playback item. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/stepBack', type=mtype) + + def stepForward(self, mtype=DEFAULT_MTYPE): + """ Step forward a chunk of time in the current playback item. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/stepForward', type=mtype) + + def stop(self, mtype=DEFAULT_MTYPE): + """ Stop the currently playing item. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/stop', type=mtype) + + def setRepeat(self, repeat, mtype=DEFAULT_MTYPE): + """ Enable repeat for the specified playback items. + + Parameters: + repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall). + mtype (str): Media type to take action against (music, photo, video). + """ + self.setParameters(repeat=repeat, mtype=mtype) + + def setShuffle(self, shuffle, mtype=DEFAULT_MTYPE): + """ Enable shuffle for the specified playback items. + + Parameters: + shuffle (int): Shuffle mode (0=off, 1=on) + mtype (str): Media type to take action against (music, photo, video). + """ + self.setParameters(shuffle=shuffle, mtype=mtype) + + def setVolume(self, volume, mtype=DEFAULT_MTYPE): + """ Enable volume for the current playback item. + + Parameters: + volume (int): Volume level (0-100). + mtype (str): Media type to take action against (music, photo, video). + """ + self.setParameters(volume=volume, mtype=mtype) + + def setAudioStream(self, audioStreamID, mtype=DEFAULT_MTYPE): + """ Select the audio stream for the current playback item (only video). + + Parameters: + audioStreamID (str): ID of the audio stream from the media object. + mtype (str): Media type to take action against (music, photo, video). + """ + self.setStreams(audioStreamID=audioStreamID, mtype=mtype) + + def setSubtitleStream(self, subtitleStreamID, mtype=DEFAULT_MTYPE): + """ Select the subtitle stream for the current playback item (only video). + + Parameters: + subtitleStreamID (str): ID of the subtitle stream from the media object. + mtype (str): Media type to take action against (music, photo, video). + """ + self.setStreams(subtitleStreamID=subtitleStreamID, mtype=mtype) + + def setVideoStream(self, videoStreamID, mtype=DEFAULT_MTYPE): + """ Select the video stream for the current playback item (only video). + + Parameters: + videoStreamID (str): ID of the video stream from the media object. + mtype (str): Media type to take action against (music, photo, video). + """ + self.setStreams(videoStreamID=videoStreamID, mtype=mtype) + + def playMedia(self, media, offset=0, **params): + """ Start playback of the specified media item. See also: + + Parameters: + media (:class:`~plexapi.media.Media`): Media item to be played back + (movie, music, photo, playlist, playqueue). + offset (int): Number of milliseconds at which to start playing with zero + representing the beginning (default 0). + **params (dict): Optional additional parameters to include in the playback request. See + also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands + + Raises: + :exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. + """ + if not self._server: + raise Unsupported('A server must be specified before using this command.') + server_url = media._server._baseurl.split(':') + server_port = server_url[-1].strip('/') + + if hasattr(media, "playlistType"): + mediatype = media.playlistType + else: + if isinstance(media, PlayQueue): + mediatype = media.items[0].listType + else: + mediatype = media.listType + + # mediatype must be in ["video", "music", "photo"] + if mediatype == "audio": + mediatype = "music" + + playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media) + self.sendCommand('playback/playMedia', **dict({ + 'providerIdentifier': 'com.plexapp.plugins.library', + 'machineIdentifier': self._server.machineIdentifier, + 'protocol': server_url[0], + 'address': server_url[1].strip('/'), + 'port': server_port, + 'offset': offset, + 'key': media.key or playqueue.selectedItem.key, + 'token': media._server.createToken(), + 'type': mediatype, + 'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID, + }, **params)) + + def setParameters(self, volume=None, shuffle=None, repeat=None, mtype=DEFAULT_MTYPE): + """ Set multiple playback parameters at once. + + Parameters: + volume (int): Volume level (0-100; optional). + shuffle (int): Shuffle mode (0=off, 1=on; optional). + repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall; optional). + mtype (str): Media type to take action against (optional music, photo, video). + """ + params = {} + if repeat is not None: + params['repeat'] = repeat + if shuffle is not None: + params['shuffle'] = shuffle + if volume is not None: + params['volume'] = volume + if mtype is not None: + params['type'] = mtype + self.sendCommand('playback/setParameters', **params) + + def setStreams(self, audioStreamID=None, subtitleStreamID=None, videoStreamID=None, mtype=DEFAULT_MTYPE): + """ Select multiple playback streams at once. + + Parameters: + audioStreamID (str): ID of the audio stream from the media object. + subtitleStreamID (str): ID of the subtitle stream from the media object. + videoStreamID (str): ID of the video stream from the media object. + mtype (str): Media type to take action against (optional music, photo, video). + """ + params = {} + if audioStreamID is not None: + params['audioStreamID'] = audioStreamID + if subtitleStreamID is not None: + params['subtitleStreamID'] = subtitleStreamID + if videoStreamID is not None: + params['videoStreamID'] = videoStreamID + if mtype is not None: + params['type'] = mtype + self.sendCommand('playback/setStreams', **params) + + # ------------------- + # Timeline Commands + def timelines(self, wait=0): + """Poll the client's timelines, create, and return timeline objects. + Some clients may not always respond to timeline requests, believe this + to be a Plex bug. + """ + t = time.time() + if t - self._timeline_cache_timestamp > 1: + self._timeline_cache_timestamp = t + timelines = self.sendCommand(ClientTimeline.key, wait=wait) or [] + self._timeline_cache = [ClientTimeline(self, data) for data in timelines] + + return self._timeline_cache + + @property + def timeline(self): + """Returns the active timeline object.""" + return next((x for x in self.timelines() if x.state != 'stopped'), None) + + def isPlayingMedia(self, includePaused=True): + """Returns True if any media is currently playing. + + Parameters: + includePaused (bool): Set True to treat currently paused items + as playing (optional; default True). + """ + state = getattr(self.timeline, "state", None) + return bool(state == 'playing' or (includePaused and state == 'paused')) + + +class ClientTimeline(PlexObject): + """Get the timeline's attributes.""" + + key = 'timeline/poll' + + def _loadData(self, data): + self._data = data + self.address = data.attrib.get('address') + self.audioStreamId = utils.cast(int, data.attrib.get('audioStreamId')) + self.autoPlay = utils.cast(bool, data.attrib.get('autoPlay')) + self.containerKey = data.attrib.get('containerKey') + self.controllable = data.attrib.get('controllable') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.itemType = data.attrib.get('itemType') + self.key = data.attrib.get('key') + self.location = data.attrib.get('location') + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.partCount = utils.cast(int, data.attrib.get('partCount')) + self.partIndex = utils.cast(int, data.attrib.get('partIndex')) + self.playQueueID = utils.cast(int, data.attrib.get('playQueueID')) + self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) + self.playQueueVersion = utils.cast(int, data.attrib.get('playQueueVersion')) + self.port = utils.cast(int, data.attrib.get('port')) + self.protocol = data.attrib.get('protocol') + self.providerIdentifier = data.attrib.get('providerIdentifier') + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.repeat = utils.cast(bool, data.attrib.get('repeat')) + self.seekRange = data.attrib.get('seekRange') + self.shuffle = utils.cast(bool, data.attrib.get('shuffle')) + self.state = data.attrib.get('state') + self.subtitleColor = data.attrib.get('subtitleColor') + self.subtitlePosition = data.attrib.get('subtitlePosition') + self.subtitleSize = utils.cast(int, data.attrib.get('subtitleSize')) + self.time = utils.cast(int, data.attrib.get('time')) + self.type = data.attrib.get('type') + self.volume = utils.cast(int, data.attrib.get('volume')) diff --git a/service.plexskipintro/plexapi/collection.py b/service.plexskipintro/plexapi/collection.py new file mode 100644 index 0000000000..ae75deec29 --- /dev/null +++ b/service.plexskipintro/plexapi/collection.py @@ -0,0 +1,520 @@ +# -*- coding: utf-8 -*- +from urllib.parse import quote_plus + +from plexapi import media, utils +from plexapi.base import PlexPartialObject +from plexapi.exceptions import BadRequest, NotFound, Unsupported +from plexapi.library import LibrarySection +from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin +from plexapi.mixins import LabelMixin, SmartFilterMixin +from plexapi.playqueue import PlayQueue +from plexapi.utils import deprecated + + +@utils.registerPlexObject +class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin, SmartFilterMixin): + """ Represents a single Collection. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'collection' + addedAt (datetime): Datetime the collection was added to the library. + art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>). + artBlurHash (str): BlurHash string for artwork image. + childCount (int): Number of items in the collection. + collectionMode (str): How the items in the collection are displayed. + collectionPublished (bool): True if the collection is published to the Plex homepage. + collectionSort (str): How to sort the items in the collection. + content (str): The filter URI string for smart collections. + contentRating (str) Content rating (PG-13; NR; TV-G). + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the collection (collection://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX). + index (int): Plex index number for the collection. + key (str): API URL (/library/metadata/<ratingkey>). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + lastRatedAt (datetime): Datetime the collection was last rated. + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + maxYear (int): Maximum year for the items in the collection. + minYear (int): Minimum year for the items in the collection. + ratingCount (int): The number of ratings. + ratingKey (int): Unique key identifying the collection. + smart (bool): True if the collection is a smart collection. + subtype (str): Media type of the items in the collection (movie, show, artist, or album). + summary (str): Summary of the collection. + thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>). + thumbBlurHash (str): BlurHash string for thumbnail image. + title (str): Name of the collection. + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'collection' + updatedAt (datatime): Datetime the collection was updated. + userRating (float): Rating of the collection (0.0 - 10.0) equaling (0 stars - 5 stars). + """ + TAG = 'Directory' + TYPE = 'collection' + + def _loadData(self, data): + self._data = data + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.art = data.attrib.get('art') + self.artBlurHash = data.attrib.get('artBlurHash') + self.childCount = utils.cast(int, data.attrib.get('childCount')) + self.collectionMode = utils.cast(int, data.attrib.get('collectionMode', '-1')) + self.collectionPublished = utils.cast(bool, data.attrib.get('collectionPublished', '0')) + self.collectionSort = utils.cast(int, data.attrib.get('collectionSort', '0')) + self.content = data.attrib.get('content') + self.contentRating = data.attrib.get('contentRating') + self.fields = self.findItems(data, media.Field) + self.guid = data.attrib.get('guid') + self.index = utils.cast(int, data.attrib.get('index')) + self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 + self.labels = self.findItems(data, media.Label) + self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.maxYear = utils.cast(int, data.attrib.get('maxYear')) + self.minYear = utils.cast(int, data.attrib.get('minYear')) + self.ratingCount = utils.cast(int, data.attrib.get('ratingCount')) + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.smart = utils.cast(bool, data.attrib.get('smart', '0')) + self.subtype = data.attrib.get('subtype') + self.summary = data.attrib.get('summary') + self.thumb = data.attrib.get('thumb') + self.thumbBlurHash = data.attrib.get('thumbBlurHash') + self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort', self.title) + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.userRating = utils.cast(float, data.attrib.get('userRating')) + self._items = None # cache for self.items + self._section = None # cache for self.section + self._filters = None # cache for self.filters + + def __len__(self): # pragma: no cover + return len(list(self.items())) + + def __iter__(self): # pragma: no cover + for item in list(self.items()): + yield item + + def __contains__(self, other): # pragma: no cover + return any(i.key == other.key for i in list(self.items())) + + def __getitem__(self, key): # pragma: no cover + return list(self.items)()[key] + + @property + def listType(self): + """ Returns the listType for the collection. """ + if self.isVideo: + return 'video' + elif self.isAudio: + return 'audio' + elif self.isPhoto: + return 'photo' + else: + raise Unsupported('Unexpected collection type') + + @property + def metadataType(self): + """ Returns the type of metadata in the collection. """ + return self.subtype + + @property + def isVideo(self): + """ Returns True if this is a video collection. """ + return self.subtype in {'movie', 'show', 'season', 'episode'} + + @property + def isAudio(self): + """ Returns True if this is an audio collection. """ + return self.subtype in {'artist', 'album', 'track'} + + @property + def isPhoto(self): + """ Returns True if this is a photo collection. """ + return self.subtype in {'photoalbum', 'photo'} + + @property + @deprecated('use "items" instead', stacklevel=3) + def children(self): + return list(self.items()) + + def filters(self): + """ Returns the search filter dict for smart collection. + The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search` + to get the list of items. + """ + if self.smart and self._filters is None: + self._filters = self._parseFilters(self.content) + return self._filters + + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to. + """ + if self._section is None: + self._section = super(Collection, self).section() + return self._section + + def item(self, title): + """ Returns the item in the collection that matches the specified title. + + Parameters: + title (str): Title of the item to return. + + Raises: + :class:`plexapi.exceptions.NotFound`: When the item is not found in the collection. + """ + for item in list(self.items()): + if item.title.lower() == title.lower(): + return item + raise NotFound('Item with title "%s" not found in the collection' % title) + + def items(self): + """ Returns a list of all items in the collection. """ + if self._items is None: + key = '%s/children' % self.key + items = self.fetchItems(key) + self._items = items + return self._items + + def get(self, title): + """ Alias to :func:`~plexapi.library.Collection.item`. """ + return self.item(title) + + def modeUpdate(self, mode=None): + """ Update the collection mode advanced setting. + + Parameters: + mode (str): One of the following values: + "default" (Library default), + "hide" (Hide Collection), + "hideItems" (Hide Items in this Collection), + "showItems" (Show this Collection and its Items) + + Example: + + .. code-block:: python + + collection.updateMode(mode="hide") + """ + mode_dict = { + 'default': -1, + 'hide': 0, + 'hideItems': 1, + 'showItems': 2 + } + key = mode_dict.get(mode) + if key is None: + raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict))) + self.editAdvanced(collectionMode=key) + + def sortUpdate(self, sort=None): + """ Update the collection order advanced setting. + + Parameters: + sort (str): One of the following values: + "realease" (Order Collection by realease dates), + "alpha" (Order Collection alphabetically), + "custom" (Custom collection order) + + Example: + + .. code-block:: python + + collection.updateSort(mode="alpha") + """ + sort_dict = { + 'release': 0, + 'alpha': 1, + 'custom': 2 + } + key = sort_dict.get(sort) + if key is None: + raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict))) + self.editAdvanced(collectionSort=key) + + def addItems(self, items): + """ Add items to the collection. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be added to the collection. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to add items to a smart collection. + """ + if self.smart: + raise BadRequest('Cannot add items to a smart collection.') + + if items and not isinstance(items, (list, tuple)): + items = [items] + + ratingKeys = [] + for item in items: + if item.type != self.subtype: # pragma: no cover + raise BadRequest('Can not mix media types when building a collection: %s and %s' % + (self.subtype, item.type)) + ratingKeys.append(str(item.ratingKey)) + + ratingKeys = ','.join(ratingKeys) + uri = '%s/library/metadata/%s' % (self._server._uriRoot(), ratingKeys) + + key = '%s/items%s' % (self.key, utils.joinArgs({ + 'uri': uri + })) + self._server.query(key, method=self._server._session.put) + + def removeItems(self, items): + """ Remove items from the collection. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be removed from the collection. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to remove items from a smart collection. + """ + if self.smart: + raise BadRequest('Cannot remove items from a smart collection.') + + if items and not isinstance(items, (list, tuple)): + items = [items] + + for item in items: + key = '%s/items/%s' % (self.key, item.ratingKey) + self._server.query(key, method=self._server._session.delete) + + def moveItem(self, item, after=None): + """ Move an item to a new position in the collection. + + Parameters: + items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be moved in the collection. + after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to move the item after in the collection. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart collection. + """ + if self.smart: + raise BadRequest('Cannot move items in a smart collection.') + + key = '%s/items/%s/move' % (self.key, item.ratingKey) + + if after: + key += '?after=%s' % after.ratingKey + + self._server.query(key, method=self._server._session.put) + + def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs): + """ Update the filters for a smart collection. + + Parameters: + libtype (str): The specific type of content to filter + (movie, show, season, episode, artist, album, track, photoalbum, photo, collection). + limit (int): Limit the number of items in the collection. + sort (str or list, optional): A string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): A dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Additional custom filters to apply to the search results. + See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying update filters for a regular collection. + """ + if not self.smart: + raise BadRequest('Cannot update filters for a regular collection.') + + section = self.section() + searchKey = section._buildSearchKey( + sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) + uri = '%s%s' % (self._server._uriRoot(), searchKey) + + key = '%s/items%s' % (self.key, utils.joinArgs({ + 'uri': uri + })) + self._server.query(key, method=self._server._session.put) + + def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs): + """ Edit the collection. + + Parameters: + title (str, optional): The title of the collection. + titleSort (str, optional): The sort title of the collection. + contentRating (str, optional): The summary of the collection. + summary (str, optional): The summary of the collection. + """ + args = {} + if title is not None: + args['title.value'] = title + args['title.locked'] = 1 + if titleSort is not None: + args['titleSort.value'] = titleSort + args['titleSort.locked'] = 1 + if contentRating is not None: + args['contentRating.value'] = contentRating + args['contentRating.locked'] = 1 + if summary is not None: + args['summary.value'] = summary + args['summary.locked'] = 1 + + args.update(kwargs) + super(Collection, self).edit(**args) + + def delete(self): + """ Delete the collection. """ + super(Collection, self).delete() + + def playQueue(self, *args, **kwargs): + """ Returns a new :class:`~plexapi.playqueue.PlayQueue` from the collection. """ + return PlayQueue.create(self._server, list(self.items()), *args, **kwargs) + + @classmethod + def _create(cls, server, title, section, items): + """ Create a regular collection. """ + if not items: + raise BadRequest('Must include items to add when creating new collection.') + + if not isinstance(section, LibrarySection): + section = server.library.section(section) + + if items and not isinstance(items, (list, tuple)): + items = [items] + + itemType = items[0].type + ratingKeys = [] + for item in items: + if item.type != itemType: # pragma: no cover + raise BadRequest('Can not mix media types when building a collection.') + ratingKeys.append(str(item.ratingKey)) + + ratingKeys = ','.join(ratingKeys) + uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys) + + key = '/library/collections%s' % utils.joinArgs({ + 'uri': uri, + 'type': utils.searchType(itemType), + 'title': title, + 'smart': 0, + 'sectionId': section.key + }) + data = server.query(key, method=server._session.post)[0] + return cls(server, data, initpath=key) + + @classmethod + def _createSmart(cls, server, title, section, limit=None, libtype=None, sort=None, filters=None, **kwargs): + """ Create a smart collection. """ + if not isinstance(section, LibrarySection): + section = server.library.section(section) + + libtype = libtype or section.TYPE + + searchKey = section._buildSearchKey( + sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) + uri = '%s%s' % (server._uriRoot(), searchKey) + + key = '/library/collections%s' % utils.joinArgs({ + 'uri': uri, + 'type': utils.searchType(libtype), + 'title': title, + 'smart': 1, + 'sectionId': section.key + }) + data = server.query(key, method=server._session.post)[0] + return cls(server, data, initpath=key) + + @classmethod + def create(cls, server, title, section, items=None, smart=False, limit=None, + libtype=None, sort=None, filters=None, **kwargs): + """ Create a collection. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): Server to create the collection on. + title (str): Title of the collection. + section (:class:`~plexapi.library.LibrarySection`, str): The library section to create the collection in. + items (List): Regular collections only, list of :class:`~plexapi.audio.Audio`, + :class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the collection. + smart (bool): True to create a smart collection. Default False. + limit (int): Smart collections only, limit the number of items in the collection. + libtype (str): Smart collections only, the specific type of content to filter + (movie, show, season, episode, artist, album, track, photoalbum, photo). + sort (str or list, optional): Smart collections only, a string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): Smart collections only, a dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Smart collections only, additional custom filters to apply to the + search results. See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When no items are included to create the collection. + :class:`plexapi.exceptions.BadRequest`: When mixing media types in the collection. + + Returns: + :class:`~plexapi.collection.Collection`: A new instance of the created Collection. + """ + if smart: + return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs) + else: + return cls._create(server, title, section, items) + + def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None, + unwatched=False, title=None): + """ Add the collection as sync item for the specified device. + See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`~plexapi.sync` module. Used only when collection contains video. + photoResolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in + the module :mod:`~plexapi.sync`. Used only when collection contains photos. + audioBitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values + from the module :mod:`~plexapi.sync`. Used only when collection contains audio. + client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`~plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`. + limit (int): maximum count of items to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current photo. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When collection is not allowed to sync. + :exc:`~plexapi.exceptions.Unsupported`: When collection content is unsupported. + + Returns: + :class:`~plexapi.sync.SyncItem`: A new instance of the created sync item. + """ + if not self.section().allowSync: + raise BadRequest('The collection is not allowed to sync') + + from plexapi.sync import SyncItem, Policy, MediaSettings + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self.title + sync_item.rootTitle = self.title + sync_item.contentType = self.listType + sync_item.metadataType = self.metadataType + sync_item.machineIdentifier = self._server.machineIdentifier + + sync_item.location = 'library:///directory/%s' % quote_plus( + '%s/children?excludeAllLeaves=1' % (self.key) + ) + sync_item.policy = Policy.create(limit, unwatched) + + if self.isVideo: + sync_item.mediaSettings = MediaSettings.createVideo(videoQuality) + elif self.isAudio: + sync_item.mediaSettings = MediaSettings.createMusic(audioBitrate) + elif self.isPhoto: + sync_item.mediaSettings = MediaSettings.createPhoto(photoResolution) + else: + raise Unsupported('Unsupported collection content') + + return myplex.sync(sync_item, client=client, clientId=clientId) diff --git a/service.plexskipintro/plexapi/config.py b/service.plexskipintro/plexapi/config.py new file mode 100644 index 0000000000..fb7a6f9b21 --- /dev/null +++ b/service.plexskipintro/plexapi/config.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +import os +from collections import defaultdict +from configparser import ConfigParser + + +class PlexConfig(ConfigParser): + """ PlexAPI configuration object. Settings are stored in an INI file within the + user's home directory and can be overridden after importing plexapi by simply + setting the value. See the documentation section 'Configuration' for more + details on available options. + + Parameters: + path (str): Path of the configuration file to load. + """ + + def __init__(self, path): + ConfigParser.__init__(self) + self.read(path) + self.data = self._asDict() + + def get(self, key, default=None, cast=None): + """ Returns the specified configuration value or <default> if not found. + + Parameters: + key (str): Configuration variable to load in the format '<section>.<variable>'. + default: Default value to use if key not found. + cast (func): Cast the value to the specified type before returning. + """ + try: + # First: check environment variable is set + envkey = 'PLEXAPI_%s' % key.upper().replace('.', '_') + value = os.environ.get(envkey) + if value is None: + # Second: check the config file has attr + section, name = key.lower().split('.') + value = self.data.get(section, {}).get(name, default) + return cast(value) if cast else value + except: # noqa: E722 + return default + + def _asDict(self): + """ Returns all configuration values as a dictionary. """ + config = defaultdict(dict) + for section in self._sections: + for name, value in list(self._sections[section].items()): + if name != '__name__': + config[section.lower()][name.lower()] = value + return dict(config) + + +def reset_base_headers(): + """ Convenience function returns a dict of all base X-Plex-* headers for session requests. """ + import plexapi + return { + 'X-Plex-Platform': plexapi.X_PLEX_PLATFORM, + 'X-Plex-Platform-Version': plexapi.X_PLEX_PLATFORM_VERSION, + 'X-Plex-Provides': plexapi.X_PLEX_PROVIDES, + 'X-Plex-Product': plexapi.X_PLEX_PRODUCT, + 'X-Plex-Version': plexapi.X_PLEX_VERSION, + 'X-Plex-Device': plexapi.X_PLEX_DEVICE, + 'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME, + 'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER, + 'X-Plex-Sync-Version': '2', + } diff --git a/service.plexskipintro/plexapi/const.py b/service.plexskipintro/plexapi/const.py new file mode 100644 index 0000000000..61c96c0b32 --- /dev/null +++ b/service.plexskipintro/plexapi/const.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +"""Constants used by plexapi.""" + +# Library version +MAJOR_VERSION = 4 +MINOR_VERSION = 8 +PATCH_VERSION = 0 +__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" +__version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/service.plexskipintro/plexapi/exceptions.py b/service.plexskipintro/plexapi/exceptions.py new file mode 100644 index 0000000000..c269c38ea3 --- /dev/null +++ b/service.plexskipintro/plexapi/exceptions.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- + + +class PlexApiException(Exception): + """ Base class for all PlexAPI exceptions. """ + pass + + +class BadRequest(PlexApiException): + """ An invalid request, generally a user error. """ + pass + + +class NotFound(PlexApiException): + """ Request media item or device is not found. """ + pass + + +class UnknownType(PlexApiException): + """ Unknown library type. """ + pass + + +class Unsupported(PlexApiException): + """ Unsupported client request. """ + pass + + +class Unauthorized(BadRequest): + """ Invalid username/password or token. """ + pass diff --git a/service.plexskipintro/plexapi/gdm.py b/service.plexskipintro/plexapi/gdm.py new file mode 100644 index 0000000000..b9fc7c5a12 --- /dev/null +++ b/service.plexskipintro/plexapi/gdm.py @@ -0,0 +1,151 @@ +""" +Support for discovery using GDM (Good Day Mate), multicast protocol by Plex. + +# Licensed Apache 2.0 +# From https://github.com/home-assistant/netdisco/netdisco/gdm.py + +Inspired by: + hippojay's plexGDM: https://github.com/hippojay/script.plexbmc.helper/resources/lib/plexgdm.py + iBaa's PlexConnect: https://github.com/iBaa/PlexConnect/PlexAPI.py +""" +import socket +import struct + + +class GDM: + """Base class to discover GDM services. + + Atrributes: + entries (List<dict>): List of server and/or client data discovered. + """ + + def __init__(self): + self.entries = [] + + def scan(self, scan_for_clients=False): + """Scan the network.""" + self.update(scan_for_clients) + + def all(self, scan_for_clients=False): + """Return all found entries. + + Will scan for entries if not scanned recently. + """ + self.scan(scan_for_clients) + return list(self.entries) + + def find_by_content_type(self, value): + """Return a list of entries that match the content_type.""" + self.scan() + return [entry for entry in self.entries + if value in entry['data']['Content-Type']] + + def find_by_data(self, values): + """Return a list of entries that match the search parameters.""" + self.scan() + return [entry for entry in self.entries + if all(item in list(entry['data'].items())) + for item in list(values.items())] + + def update(self, scan_for_clients): + """Scan for new GDM services. + + Examples of the dict list assigned to self.entries by this function: + + Server: + + [{'data': { + 'Content-Type': 'plex/media-server', + 'Host': '53f4b5b6023d41182fe88a99b0e714ba.plex.direct', + 'Name': 'myfirstplexserver', + 'Port': '32400', + 'Resource-Identifier': '646ab0aa8a01c543e94ba975f6fd6efadc36b7', + 'Updated-At': '1585769946', + 'Version': '1.18.8.2527-740d4c206', + }, + 'from': ('10.10.10.100', 32414)}] + + Clients: + + [{'data': {'Content-Type': 'plex/media-player', + 'Device-Class': 'stb', + 'Name': 'plexamp', + 'Port': '36000', + 'Product': 'Plexamp', + 'Protocol': 'plex', + 'Protocol-Capabilities': 'timeline,playback,playqueues,playqueues-creation', + 'Protocol-Version': '1', + 'Resource-Identifier': 'b6e57a3f-e0f8-494f-8884-f4b58501467e', + 'Version': '1.1.0', + }, + 'from': ('10.10.10.101', 32412)}] + """ + + gdm_msg = 'M-SEARCH * HTTP/1.0'.encode('ascii') + gdm_timeout = 1 + + self.entries = [] + known_responses = [] + + # setup socket for discovery -> multicast message + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(gdm_timeout) + + # Set the time-to-live for messages for local network + sock.setsockopt(socket.IPPROTO_IP, + socket.IP_MULTICAST_TTL, + struct.pack("B", gdm_timeout)) + + if scan_for_clients: + # setup socket for broadcast to Plex clients + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + gdm_ip = '255.255.255.255' + gdm_port = 32412 + else: + # setup socket for multicast to Plex server(s) + gdm_ip = '239.0.0.250' + gdm_port = 32414 + + try: + # Send data to the multicast group + sock.sendto(gdm_msg, (gdm_ip, gdm_port)) + + # Look for responses from all recipients + while True: + try: + bdata, host = sock.recvfrom(1024) + data = bdata.decode('utf-8') + if '200 OK' in data.splitlines()[0]: + ddata = {k: v.strip() for (k, v) in ( + line.split(':') for line in + data.splitlines() if ':' in line)} + identifier = ddata.get('Resource-Identifier') + if identifier and identifier in known_responses: + continue + known_responses.append(identifier) + self.entries.append({'data': ddata, + 'from': host}) + except socket.timeout: + break + finally: + sock.close() + + +def main(): + """Test GDM discovery.""" + from pprint import pprint + + gdm = GDM() + + pprint("Scanning GDM for servers...") + gdm.scan() + pprint(gdm.entries) + + pprint("Scanning GDM for clients...") + gdm.scan(scan_for_clients=True) + pprint(gdm.entries) + + +if __name__ == "__main__": + main() diff --git a/service.plexskipintro/plexapi/library.py b/service.plexskipintro/plexapi/library.py new file mode 100644 index 0000000000..f5482b8a06 --- /dev/null +++ b/service.plexskipintro/plexapi/library.py @@ -0,0 +1,2494 @@ +# -*- coding: utf-8 -*- +import re +from datetime import datetime +from urllib.parse import quote, quote_plus, urlencode + +from plexapi import X_PLEX_CONTAINER_SIZE, log, media, utils +from plexapi.base import OPERATORS, PlexObject +from plexapi.exceptions import BadRequest, NotFound +from plexapi.settings import Setting +from plexapi.utils import deprecated + + +class Library(PlexObject): + """ Represents a PlexServer library. This contains all sections of media defined + in your Plex server including video, shows and audio. + + Attributes: + key (str): '/library' + identifier (str): Unknown ('com.plexapp.plugins.library'). + mediaTagVersion (str): Unknown (/system/bundle/media/flags/) + server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to. + title1 (str): 'Plex Library' (not sure how useful this is). + title2 (str): Second title (this is blank on my setup). + """ + key = '/library' + + def _loadData(self, data): + self._data = data + self.identifier = data.attrib.get('identifier') + self.mediaTagVersion = data.attrib.get('mediaTagVersion') + self.title1 = data.attrib.get('title1') + self.title2 = data.attrib.get('title2') + self._sectionsByID = {} # cached sections by key + self._sectionsByTitle = {} # cached sections by title + + def _loadSections(self): + """ Loads and caches all the library sections. """ + key = '/library/sections' + self._sectionsByID = {} + self._sectionsByTitle = {} + for elem in self._server.query(key): + for cls in (MovieSection, ShowSection, MusicSection, PhotoSection): + if elem.attrib.get('type') == cls.TYPE: + section = cls(self._server, elem, key) + self._sectionsByID[section.key] = section + self._sectionsByTitle[section.title.lower()] = section + + def sections(self): + """ Returns a list of all media sections in this library. Library sections may be any of + :class:`~plexapi.library.MovieSection`, :class:`~plexapi.library.ShowSection`, + :class:`~plexapi.library.MusicSection`, :class:`~plexapi.library.PhotoSection`. + """ + self._loadSections() + return list(self._sectionsByID.values()) + + def section(self, title): + """ Returns the :class:`~plexapi.library.LibrarySection` that matches the specified title. + + Parameters: + title (str): Title of the section to return. + """ + if not self._sectionsByTitle or title not in self._sectionsByTitle: + self._loadSections() + try: + return self._sectionsByTitle[title.lower()] + except KeyError: + raise NotFound('Invalid library section: %s' % title) from None + + def sectionByID(self, sectionID): + """ Returns the :class:`~plexapi.library.LibrarySection` that matches the specified sectionID. + + Parameters: + sectionID (int): ID of the section to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: The library section ID is not found on the server. + """ + if not self._sectionsByID or sectionID not in self._sectionsByID: + self._loadSections() + try: + return self._sectionsByID[sectionID] + except KeyError: + raise NotFound('Invalid library sectionID: %s' % sectionID) from None + + def all(self, **kwargs): + """ Returns a list of all media from all library sections. + This may be a very large dataset to retrieve. + """ + items = [] + for section in self.sections(): + for item in section.all(**kwargs): + items.append(item) + return items + + def onDeck(self): + """ Returns a list of all media items on deck. """ + return self.fetchItems('/library/onDeck') + + def recentlyAdded(self): + """ Returns a list of all media items recently added. """ + return self.fetchItems('/library/recentlyAdded') + + def search(self, title=None, libtype=None, **kwargs): + """ Searching within a library section is much more powerful. It seems certain + attributes on the media objects can be targeted to filter this search down + a bit, but I havent found the documentation for it. + + Example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items + such as actor=<id> seem to work, but require you already know the id of the actor. + TLDR: This is untested but seems to work. Use library section search when you can. + """ + args = {} + if title: + args['title'] = title + if libtype: + args['type'] = utils.searchType(libtype) + for attr, value in list(kwargs.items()): + args[attr] = value + key = '/library/all%s' % utils.joinArgs(args) + return self.fetchItems(key) + + def cleanBundles(self): + """ Poster images and other metadata for items in your library are kept in "bundle" + packages. When you remove items from your library, these bundles aren't immediately + removed. Removing these old bundles can reduce the size of your install. By default, your + server will automatically clean up old bundles once a week as part of Scheduled Tasks. + """ + # TODO: Should this check the response for success or the correct mediaprefix? + self._server.query('/library/clean/bundles?async=1', method=self._server._session.put) + + def emptyTrash(self): + """ If a library has items in the Library Trash, use this option to empty the Trash. """ + for section in self.sections(): + section.emptyTrash() + + def optimize(self): + """ The Optimize option cleans up the server database from unused or fragmented data. + For example, if you have deleted or added an entire library or many items in a + library, you may like to optimize the database. + """ + self._server.query('/library/optimize?async=1', method=self._server._session.put) + + def update(self): + """ Scan this library for new items.""" + self._server.query('/library/sections/all/refresh') + + def cancelUpdate(self): + """ Cancel a library update. """ + key = '/library/sections/all/refresh' + self._server.query(key, method=self._server._session.delete) + + def refresh(self): + """ Forces a download of fresh media information from the internet. + This can take a long time. Any locked fields are not modified. + """ + self._server.query('/library/sections/all/refresh?force=1') + + def deleteMediaPreviews(self): + """ Delete the preview thumbnails for the all sections. This cannot be + undone. Recreating media preview files can take hours or even days. + """ + for section in self.sections(): + section.deleteMediaPreviews() + + def add(self, name='', type='', agent='', scanner='', location='', language='en', *args, **kwargs): + """ Simplified add for the most common options. + + Parameters: + name (str): Name of the library + agent (str): Example com.plexapp.agents.imdb + type (str): movie, show, # check me + location (str): /path/to/files + language (str): Two letter language fx en + kwargs (dict): Advanced options should be passed as a dict. where the id is the key. + + **Photo Preferences** + + * **agent** (str): com.plexapp.agents.none + * **enableAutoPhotoTags** (bool): Tag photos. Default value false. + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex Photo Scanner + + **Movie Preferences** + + * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, tv.plex.agents.movie, + com.plexapp.agents.themoviedb + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **enableCinemaTrailers** (bool): Enable Cinema Trailers. Default value true. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex Movie, Plex Movie Scanner, Plex Video Files Scanner, Plex Video Files + + **IMDB Movie Options** (com.plexapp.agents.imdb) + + * **title** (bool): Localized titles. Default value false. + * **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true. + * **only_trailers** (bool): Skip extras which aren't trailers. Default value false. + * **redband** (bool): Use red band (restricted audiences) trailers when available. Default value false. + * **native_subs** (bool): Include extras with subtitles in Library language. Default value false. + * **cast_list** (int): Cast List Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **ratings** (int): Ratings Source, Default value 0 Possible options: + 0:Rotten Tomatoes, 1:IMDb, 2:The Movie Database. + * **summary** (int): Plot Summary Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **country** (int): Default value 46 Possible options 0:Argentina, 1:Australia, 2:Austria, + 3:Belgium, 4:Belize, 5:Bolivia, 6:Brazil, 7:Canada, 8:Chile, 9:Colombia, 10:Costa Rica, + 11:Czech Republic, 12:Denmark, 13:Dominican Republic, 14:Ecuador, 15:El Salvador, + 16:France, 17:Germany, 18:Guatemala, 19:Honduras, 20:Hong Kong SAR, 21:Ireland, + 22:Italy, 23:Jamaica, 24:Korea, 25:Liechtenstein, 26:Luxembourg, 27:Mexico, 28:Netherlands, + 29:New Zealand, 30:Nicaragua, 31:Panama, 32:Paraguay, 33:Peru, 34:Portugal, + 35:Peoples Republic of China, 36:Puerto Rico, 37:Russia, 38:Singapore, 39:South Africa, + 40:Spain, 41:Sweden, 42:Switzerland, 43:Taiwan, 44:Trinidad, 45:United Kingdom, + 46:United States, 47:Uruguay, 48:Venezuela. + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **usage** (bool): Send anonymous usage data to Plex. Default value true. + + **TheMovieDB Movie Options** (com.plexapp.agents.themoviedb) + + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **country** (int): Country (used for release date and content rating). Default value 47 Possible + options 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, 6:Bolivia, 7:Brazil, 8:Canada, + 9:Chile, 10:Colombia, 11:Costa Rica, 12:Czech Republic, 13:Denmark, 14:Dominican Republic, 15:Ecuador, + 16:El Salvador, 17:France, 18:Germany, 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland, + 23:Italy, 24:Jamaica, 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands, + 30:New Zealand, 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal, + 36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, 40:South Africa, 41:Spain, + 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, 46:United Kingdom, 47:United States, 48:Uruguay, + 49:Venezuela. + + **Show Preferences** + + * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.thetvdb, com.plexapp.agents.themoviedb, + tv.plex.agents.series + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **episodeSort** (int): Episode order. Default -1 Possible options: 0:Oldest first, 1:Newest first. + * **flattenSeasons** (int): Seasons. Default value 0 Possible options: 0:Show,1:Hide. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex TV Series, Plex Series Scanner + + **TheTVDB Show Options** (com.plexapp.agents.thetvdb) + + * **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true. + * **native_subs** (bool): Include extras with subtitles in Library language. Default value false. + + **TheMovieDB Show Options** (com.plexapp.agents.themoviedb) + + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **country** (int): Country (used for release date and content rating). Default value 47 options + 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, 6:Bolivia, 7:Brazil, 8:Canada, 9:Chile, + 10:Colombia, 11:Costa Rica, 12:Czech Republic, 13:Denmark, 14:Dominican Republic, 15:Ecuador, + 16:El Salvador, 17:France, 18:Germany, 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland, + 23:Italy, 24:Jamaica, 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands, + 30:New Zealand, 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal, + 36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, 40:South Africa, + 41:Spain, 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, 46:United Kingdom, 47:United States, + 48:Uruguay, 49:Venezuela. + + **Other Video Preferences** + + * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, com.plexapp.agents.themoviedb + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **enableCinemaTrailers** (bool): Enable Cinema Trailers. Default value true. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex Movie Scanner, Plex Video Files Scanner + + **IMDB Other Video Options** (com.plexapp.agents.imdb) + + * **title** (bool): Localized titles. Default value false. + * **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true. + * **only_trailers** (bool): Skip extras which aren't trailers. Default value false. + * **redband** (bool): Use red band (restricted audiences) trailers when available. Default value false. + * **native_subs** (bool): Include extras with subtitles in Library language. Default value false. + * **cast_list** (int): Cast List Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **ratings** (int): Ratings Source Default value 0 Possible options: + 0:Rotten Tomatoes,1:IMDb,2:The Movie Database. + * **summary** (int): Plot Summary Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **country** (int): Country: Default value 46 Possible options: 0:Argentina, 1:Australia, 2:Austria, + 3:Belgium, 4:Belize, 5:Bolivia, 6:Brazil, 7:Canada, 8:Chile, 9:Colombia, 10:Costa Rica, + 11:Czech Republic, 12:Denmark, 13:Dominican Republic, 14:Ecuador, 15:El Salvador, 16:France, + 17:Germany, 18:Guatemala, 19:Honduras, 20:Hong Kong SAR, 21:Ireland, 22:Italy, 23:Jamaica, + 24:Korea, 25:Liechtenstein, 26:Luxembourg, 27:Mexico, 28:Netherlands, 29:New Zealand, 30:Nicaragua, + 31:Panama, 32:Paraguay, 33:Peru, 34:Portugal, 35:Peoples Republic of China, 36:Puerto Rico, + 37:Russia, 38:Singapore, 39:South Africa, 40:Spain, 41:Sweden, 42:Switzerland, 43:Taiwan, 44:Trinidad, + 45:United Kingdom, 46:United States, 47:Uruguay, 48:Venezuela. + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **usage** (bool): Send anonymous usage data to Plex. Default value true. + + **TheMovieDB Other Video Options** (com.plexapp.agents.themoviedb) + + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **country** (int): Country (used for release date and content rating). Default + value 47 Possible options 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, + 6:Bolivia, 7:Brazil, 8:Canada, 9:Chile, 10:Colombia, 11:Costa Rica, 12:Czech Republic, + 13:Denmark, 14:Dominican Republic, 15:Ecuador, 16:El Salvador, 17:France, 18:Germany, + 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland, 23:Italy, 24:Jamaica, + 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands, 30:New Zealand, + 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal, + 36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, + 40:South Africa, 41:Spain, 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, + 46:United Kingdom, 47:United States, 48:Uruguay, 49:Venezuela. + """ + part = '/library/sections?name=%s&type=%s&agent=%s&scanner=%s&language=%s&location=%s' % ( + quote_plus(name), type, agent, quote_plus(scanner), language, quote_plus(location)) # noqa E126 + if kwargs: + part += urlencode(kwargs) + return self._server.query(part, method=self._server._session.post) + + def history(self, maxresults=9999999, mindate=None): + """ Get Play History for all library Sections for the owner. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + hist = [] + for section in self.sections(): + hist.extend(section.history(maxresults=maxresults, mindate=mindate)) + return hist + + +class LibrarySection(PlexObject): + """ Base class for a single library section. + + Attributes: + agent (str): The metadata agent used for the library section (com.plexapp.agents.imdb, etc). + allowSync (bool): True if you allow syncing content from the library section. + art (str): Background artwork used to respresent the library section. + composite (str): Composite image used to represent the library section. + createdAt (datetime): Datetime the library section was created. + filters (bool): True if filters are available for the library section. + key (int): Key (or ID) of this library section. + language (str): Language represented in this section (en, xn, etc). + locations (List<str>): List of folder paths added to the library section. + refreshing (bool): True if this section is currently being refreshed. + scanner (str): Internal scanner used to find media (Plex Movie Scanner, Plex Premium Music Scanner, etc.) + thumb (str): Thumbnail image used to represent the library section. + title (str): Name of the library section. + type (str): Type of content section represents (movie, show, artist, photo). + updatedAt (datetime): Datetime the library section was last updated. + uuid (str): Unique id for the section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63) + """ + + def _loadData(self, data): + self._data = data + self.agent = data.attrib.get('agent') + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.art = data.attrib.get('art') + self.composite = data.attrib.get('composite') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.filters = utils.cast(bool, data.attrib.get('filters')) + self.key = utils.cast(int, data.attrib.get('key')) + self.language = data.attrib.get('language') + self.locations = self.listAttrs(data, 'path', etag='Location') + self.refreshing = utils.cast(bool, data.attrib.get('refreshing')) + self.scanner = data.attrib.get('scanner') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.uuid = data.attrib.get('uuid') + # Private attrs as we dont want a reload. + self._filterTypes = None + self._fieldTypes = None + self._totalViewSize = None + self._totalSize = None + self._totalDuration = None + self._totalStorage = None + + def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs): + """ Load the specified key to find and build all items with the specified tag + and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details + on how this is used. + + Parameters: + container_start (None, int): offset to get a subset of the data + container_size (None, int): How many items in data + + """ + url_kw = {} + if container_start is not None: + url_kw["X-Plex-Container-Start"] = container_start + if container_size is not None: + url_kw["X-Plex-Container-Size"] = container_size + + if ekey is None: + raise BadRequest('ekey was not provided') + data = self._server.query(ekey, params=url_kw) + + if '/all' in ekey: + # totalSize is only included in the xml response + # if container size is used. + total_size = data.attrib.get("totalSize") or data.attrib.get("size") + self._totalViewSize = utils.cast(int, total_size) + + items = self.findItems(data, cls, ekey, **kwargs) + + librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + if librarySectionID: + for item in items: + item.librarySectionID = librarySectionID + return items + + @property + def totalSize(self): + """ Returns the total number of items in the library for the default library type. """ + if self._totalSize is None: + self._totalSize = self.totalViewSize(includeCollections=False) + return self._totalSize + + @property + def totalDuration(self): + """ Returns the total duration (in milliseconds) of items in the library. """ + if self._totalDuration is None: + self._getTotalDurationStorage() + return self._totalDuration + + @property + def totalStorage(self): + """ Returns the total storage (in bytes) of items in the library. """ + if self._totalStorage is None: + self._getTotalDurationStorage() + return self._totalStorage + + def _getTotalDurationStorage(self): + """ Queries the Plex server for the total library duration and storage and caches the values. """ + data = self._server.query('/media/providers?includeStorage=1') + xpath = ( + './MediaProvider[@identifier="com.plexapp.plugins.library"]' + '/Feature[@type="content"]' + '/Directory[@id="%s"]' + ) % self.key + directory = next(iter(data.findall(xpath)), None) + if directory: + self._totalDuration = utils.cast(int, directory.attrib.get('durationTotal')) + self._totalStorage = utils.cast(int, directory.attrib.get('storageTotal')) + + def totalViewSize(self, libtype=None, includeCollections=True): + """ Returns the total number of items in the library for a specified libtype. + The number of items for the default library type will be returned if no libtype is specified. + (e.g. Specify ``libtype='episode'`` for the total number of episodes + or ``libtype='albums'`` for the total number of albums.) + + Parameters: + libtype (str, optional): The type of items to return the total number for (movie, show, season, episode, + artist, album, track, photoalbum). Default is the main library type. + includeCollections (bool, optional): True or False to include collections in the total number. + Default is True. + """ + args = { + 'includeCollections': int(bool(includeCollections)), + 'X-Plex-Container-Start': 0, + 'X-Plex-Container-Size': 0 + } + if libtype is not None: + if libtype == 'photo': + args['clusterZoomLevel'] = 1 + else: + args['type'] = utils.searchType(libtype) + part = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args)) + data = self._server.query(part) + return utils.cast(int, data.attrib.get("totalSize")) + + def delete(self): + """ Delete a library section. """ + try: + return self._server.query('/library/sections/%s' % self.key, method=self._server._session.delete) + except BadRequest: # pragma: no cover + msg = 'Failed to delete library %s' % self.key + msg += 'You may need to allow this permission in your Plex settings.' + log.error(msg) + raise + + def reload(self): + """ Reload the data for the library section. """ + self._server.library._loadSections() + newLibrary = self._server.library.sectionByID(self.key) + self.__dict__.update(newLibrary.__dict__) + return self + + def edit(self, agent=None, **kwargs): + """ Edit a library (Note: agent is required). See :class:`~plexapi.library.Library` for example usage. + + Parameters: + kwargs (dict): Dict of settings to edit. + """ + if not agent: + agent = self.agent + part = '/library/sections/%s?agent=%s&%s' % (self.key, agent, urlencode(kwargs)) + self._server.query(part, method=self._server._session.put) + + def get(self, title): + """ Returns the media item with the specified title. + + Parameters: + title (str): Title of the item to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: The title is not found in the library. + """ + key = '/library/sections/%s/all?includeGuids=1&title=%s' % (self.key, quote(str(title), safe='')) + return self.fetchItem(key, title__iexact=title) + + def getGuid(self, guid): + """ Returns the media item with the specified external IMDB, TMDB, or TVDB ID. + Note: This search uses a PlexAPI operator so performance may be slow. All items from the + entire Plex library need to be retrieved for each guid search. It is recommended to create + your own lookup dictionary if you are searching for a lot of external guids. + + Parameters: + guid (str): The external guid of the item to return. + Examples: IMDB ``imdb://tt0944947``, TMDB ``tmdb://1399``, TVDB ``tvdb://121361``. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: The guid is not found in the library. + + Example: + + .. code-block:: python + + # This will retrieve all items in the entire library 3 times + result1 = library.getGuid('imdb://tt0944947') + result2 = library.getGuid('tmdb://1399') + result3 = library.getGuid('tvdb://121361') + + # This will only retrieve all items in the library once to create a lookup dictionary + guidLookup = {guid.id: item for item in library.all() for guid in item.guids} + result1 = guidLookup['imdb://tt0944947'] + result2 = guidLookup['tmdb://1399'] + result3 = guidLookup['tvdb://121361'] + + """ + key = '/library/sections/%s/all?includeGuids=1' % self.key + return self.fetchItem(key, Guid__id__iexact=guid) + + def all(self, libtype=None, **kwargs): + """ Returns a list of all items from this library section. + See description of :func:`~plexapi.library.LibrarySection.search()` for details about filtering / sorting. + """ + libtype = libtype or self.TYPE + return self.search(libtype=libtype, **kwargs) + + def folders(self): + """ Returns a list of available :class:`~plexapi.library.Folder` for this library section. + """ + key = '/library/sections/%s/folder' % self.key + return self.fetchItems(key, Folder) + + def hubs(self): + """ Returns a list of available :class:`~plexapi.library.Hub` for this library section. + """ + key = '/hubs/sections/%s' % self.key + return self.fetchItems(key) + + def agents(self): + """ Returns a list of available :class:`~plexapi.media.Agent` for this library section. + """ + return self._server.agents(utils.searchType(self.type)) + + def settings(self): + """ Returns a list of all library settings. """ + key = '/library/sections/%s/prefs' % self.key + data = self._server.query(key) + return self.findItems(data, cls=Setting) + + def editAdvanced(self, **kwargs): + """ Edit a library's advanced settings. """ + data = {} + idEnums = {} + key = 'prefs[%s]' + + for setting in self.settings(): + if setting.type != 'bool': + idEnums[setting.id] = setting.enumValues + else: + idEnums[setting.id] = {0: False, 1: True} + + for settingID, value in list(kwargs.items()): + try: + enums = idEnums[settingID] + except KeyError: + raise NotFound('%s not found in %s' % (value, list(idEnums.keys()))) + if value in enums: + data[key % settingID] = value + else: + raise NotFound('%s not found in %s' % (value, enums)) + + self.edit(**data) + + def defaultAdvanced(self): + """ Edit all of library's advanced settings to default. """ + data = {} + key = 'prefs[%s]' + for setting in self.settings(): + if setting.type == 'bool': + data[key % setting.id] = int(setting.default) + else: + data[key % setting.id] = setting.default + + self.edit(**data) + + def timeline(self): + """ Returns a timeline query for this library section. """ + key = '/library/sections/%s/timeline' % self.key + data = self._server.query(key) + return LibraryTimeline(self, data) + + def onDeck(self): + """ Returns a list of media items on deck from this library section. """ + key = '/library/sections/%s/onDeck' % self.key + return self.fetchItems(key) + + def recentlyAdded(self, maxresults=50, libtype=None): + """ Returns a list of media items recently added from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo). Default is the main library type. + """ + libtype = libtype or self.TYPE + return self.search(sort='addedAt:desc', maxresults=maxresults, libtype=libtype) + + def firstCharacter(self): + key = '/library/sections/%s/firstCharacter' % self.key + return self.fetchItems(key, cls=FirstCharacter) + + def analyze(self): + """ Run an analysis on all of the items in this library section. See + See :func:`~plexapi.base.PlexPartialObject.analyze` for more details. + """ + key = '/library/sections/%s/analyze' % self.key + self._server.query(key, method=self._server._session.put) + + def emptyTrash(self): + """ If a section has items in the Trash, use this option to empty the Trash. """ + key = '/library/sections/%s/emptyTrash' % self.key + self._server.query(key, method=self._server._session.put) + + def update(self, path=None): + """ Scan this section for new media. + + Parameters: + path (str, optional): Full path to folder to scan. + """ + key = '/library/sections/%s/refresh' % self.key + if path is not None: + key += '?path=%s' % quote_plus(path) + self._server.query(key) + + def cancelUpdate(self): + """ Cancel update of this Library Section. """ + key = '/library/sections/%s/refresh' % self.key + self._server.query(key, method=self._server._session.delete) + + def refresh(self): + """ Forces a download of fresh media information from the internet. + This can take a long time. Any locked fields are not modified. + """ + key = '/library/sections/%s/refresh?force=1' % self.key + self._server.query(key) + + def deleteMediaPreviews(self): + """ Delete the preview thumbnails for items in this library. This cannot + be undone. Recreating media preview files can take hours or even days. + """ + key = '/library/sections/%s/indexes' % self.key + self._server.query(key, method=self._server._session.delete) + + def _loadFilters(self): + """ Retrieves and caches the list of :class:`~plexapi.library.FilteringType` and + list of :class:`~plexapi.library.FilteringFieldType` for this library section. + """ + _key = ('/library/sections/%s/%s?includeMeta=1&includeAdvanced=1' + '&X-Plex-Container-Start=0&X-Plex-Container-Size=0') + + key = _key % (self.key, 'all') + data = self._server.query(key) + self._filterTypes = self.findItems(data, FilteringType, rtag='Meta') + self._fieldTypes = self.findItems(data, FilteringFieldType, rtag='Meta') + + if self.TYPE != 'photo': # No collections for photo library + key = _key % (self.key, 'collections') + data = self._server.query(key) + self._filterTypes.extend(self.findItems(data, FilteringType, rtag='Meta')) + + def filterTypes(self): + """ Returns a list of available :class:`~plexapi.library.FilteringType` for this library section. """ + if self._filterTypes is None: + self._loadFilters() + return self._filterTypes + + def getFilterType(self, libtype=None): + """ Returns a :class:`~plexapi.library.FilteringType` for a specified libtype. + + Parameters: + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection). + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown libtype for this library. + """ + libtype = libtype or self.TYPE + try: + return next(f for f in self.filterTypes() if f.type == libtype) + except StopIteration: + availableLibtypes = [f.type for f in self.filterTypes()] + raise NotFound('Unknown libtype "%s" for this library. ' + 'Available libtypes: %s' + % (libtype, availableLibtypes)) from None + + def fieldTypes(self): + """ Returns a list of available :class:`~plexapi.library.FilteringFieldType` for this library section. """ + if self._fieldTypes is None: + self._loadFilters() + return self._fieldTypes + + def getFieldType(self, fieldType): + """ Returns a :class:`~plexapi.library.FilteringFieldType` for a specified fieldType. + + Parameters: + fieldType (str): The data type for the field (tag, integer, string, boolean, date, + subtitleLanguage, audioLanguage, resolution). + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown fieldType for this library. + """ + try: + return next(f for f in self.fieldTypes() if f.type == fieldType) + except StopIteration: + availableFieldTypes = [f.type for f in self.fieldTypes()] + raise NotFound('Unknown field type "%s" for this library. ' + 'Available field types: %s' + % (fieldType, availableFieldTypes)) from None + + def listFilters(self, libtype=None): + """ Returns a list of available :class:`~plexapi.library.FilteringFilter` for a specified libtype. + This is the list of options in the filter dropdown menu + (`screenshot <../_static/images/LibrarySection.listFilters.png>`__). + + Parameters: + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection). + + Example: + + .. code-block:: python + + availableFilters = [f.filter for f in library.listFilters()] + print("Available filter fields:", availableFilters) + + """ + return self.getFilterType(libtype).filters + + def listSorts(self, libtype=None): + """ Returns a list of available :class:`~plexapi.library.FilteringSort` for a specified libtype. + This is the list of options in the sorting dropdown menu + (`screenshot <../_static/images/LibrarySection.listSorts.png>`__). + + Parameters: + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection). + + Example: + + .. code-block:: python + + availableSorts = [f.key for f in library.listSorts()] + print("Available sort fields:", availableSorts) + + """ + return self.getFilterType(libtype).sorts + + def listFields(self, libtype=None): + """ Returns a list of available :class:`~plexapi.library.FilteringFields` for a specified libtype. + This is the list of options in the custom filter dropdown menu + (`screenshot <../_static/images/LibrarySection.search.png>`__). + + Parameters: + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection). + + Example: + + .. code-block:: python + + availableFields = [f.key.split('.')[-1] for f in library.listFields()] + print("Available fields:", availableFields) + + """ + return self.getFilterType(libtype).fields + + def listOperators(self, fieldType): + """ Returns a list of available :class:`~plexapi.library.FilteringOperator` for a specified fieldType. + This is the list of options in the custom filter operator dropdown menu + (`screenshot <../_static/images/LibrarySection.search.png>`__). + + Parameters: + fieldType (str): The data type for the field (tag, integer, string, boolean, date, + subtitleLanguage, audioLanguage, resolution). + + Example: + + .. code-block:: python + + field = 'genre' # Available filter field from listFields() + filterField = next(f for f in library.listFields() if f.key.endswith(field)) + availableOperators = [o.key for o in library.listOperators(filterField.type)] + print("Available operators for %s:" % field, availableOperators) + + """ + return self.getFieldType(fieldType).operators + + def listFilterChoices(self, field, libtype=None): + """ Returns a list of available :class:`~plexapi.library.FilterChoice` for a specified + :class:`~plexapi.library.FilteringFilter` or filter field. + This is the list of available values for a custom filter + (`screenshot <../_static/images/LibrarySection.search.png>`__). + + Parameters: + field (str): :class:`~plexapi.library.FilteringFilter` object, + or the name of the field (genre, year, contentRating, etc.). + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: Invalid filter field. + :exc:`~plexapi.exceptions.NotFound`: Unknown filter field. + + Example: + + .. code-block:: python + + field = 'genre' # Available filter field from listFilters() + availableChoices = [f.title for f in library.listFilterChoices(field)] + print("Available choices for %s:" % field, availableChoices) + + """ + if isinstance(field, str): + match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+)', field) + if not match: + raise BadRequest('Invalid filter field: %s' % field) + _libtype, field = match.groups() + libtype = _libtype or libtype or self.TYPE + try: + field = next(f for f in self.listFilters(libtype) if f.filter == field) + except StopIteration: + availableFilters = [f.filter for f in self.listFilters(libtype)] + raise NotFound('Unknown filter field "%s" for libtype "%s". ' + 'Available filters: %s' + % (field, libtype, availableFilters)) from None + + data = self._server.query(field.key) + return self.findItems(data, FilterChoice) + + def _validateFilterField(self, field, values, libtype=None): + """ Validates a filter field and values are available as a custom filter for the library. + Returns the validated field and values as a URL encoded parameter string. + """ + match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+)([!<>=&]*)', field) + if not match: + raise BadRequest('Invalid filter field: %s' % field) + _libtype, field, operator = match.groups() + libtype = _libtype or libtype or self.TYPE + + try: + filterField = next(f for f in self.listFields(libtype) if f.key.split('.')[-1] == field) + except StopIteration: + for filterType in reversed(self.filterTypes()): + if filterType.type != libtype: + filterField = next((f for f in filterType.fields if f.key.split('.')[-1] == field), None) + if filterField: + break + else: + availableFields = [f.key for f in self.listFields(libtype)] + raise NotFound('Unknown filter field "%s" for libtype "%s". ' + 'Available filter fields: %s' + % (field, libtype, availableFields)) from None + + field = filterField.key + operator = self._validateFieldOperator(filterField, operator) + result = self._validateFieldValue(filterField, values, libtype) + + if operator == '&=': + args = {field: result} + return urlencode(args, doseq=True) + else: + args = {field + operator[:-1]: ','.join(result)} + return urlencode(args) + + def _validateFieldOperator(self, filterField, operator): + """ Validates filter operator is in the available operators. + Returns the validated operator string. + """ + fieldType = self.getFieldType(filterField.type) + + and_operator = False + if operator in {'&', '&='}: + and_operator = True + operator = '' + if fieldType.type == 'string' and operator in {'=', '!='}: + operator += '=' + operator = (operator[:-1] if operator[-1:] == '=' else operator) + '=' + + try: + next(o for o in fieldType.operators if o.key == operator) + except StopIteration: + availableOperators = [o.key for o in self.listOperators(filterField.type)] + raise NotFound('Unknown operator "%s" for filter field "%s". ' + 'Available operators: %s' + % (operator, filterField.key, availableOperators)) from None + + return '&=' if and_operator else operator + + def _validateFieldValue(self, filterField, values, libtype=None): + """ Validates filter values are the correct datatype and in the available filter choices. + Returns the validated list of values. + """ + if not isinstance(values, (list, tuple)): + values = [values] + + fieldType = self.getFieldType(filterField.type) + results = [] + + try: + for value in values: + if fieldType.type == 'boolean': + value = int(bool(value)) + elif fieldType.type == 'date': + value = self._validateFieldValueDate(value) + elif fieldType.type == 'integer': + value = float(value) if '.' in str(value) else int(value) + elif fieldType.type == 'string': + value = str(value) + elif fieldType.type in {'tag', 'subtitleLanguage', 'audioLanguage', 'resolution'}: + value = self._validateFieldValueTag(value, filterField, libtype) + results.append(str(value)) + except (ValueError, AttributeError): + raise BadRequest('Invalid value "%s" for filter field "%s", value should be type %s' + % (value, filterField.key, fieldType.type)) from None + + return results + + def _validateFieldValueDate(self, value): + """ Validates a filter date value. A filter date value can be a datetime object, + a relative date (e.g. -30d), or a date in YYYY-MM-DD format. + """ + if isinstance(value, datetime): + return int(value.timestamp()) + elif re.match(r'^-?\d+(mon|[smhdwy])$', value): + return '-' + value.lstrip('-') + else: + return int(utils.toDatetime(value, '%Y-%m-%d').timestamp()) + + def _validateFieldValueTag(self, value, filterField, libtype): + """ Validates a filter tag value. A filter tag value can be a :class:`~plexapi.library.FilterChoice` object, + a :class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*), + or the exact id :attr:`MediaTag.id` (*int*). + """ + if isinstance(value, FilterChoice): + return value.key + if isinstance(value, media.MediaTag): + value = str(value.id or value.tag) + else: + value = str(value) + filterChoices = self.listFilterChoices(filterField.key, libtype) + matchValue = value.lower() + return next((f.key for f in filterChoices if matchValue in {f.key.lower(), f.title.lower()}), value) + + def _validateSortFields(self, sort, libtype=None): + """ Validates a list of filter sort fields is available for the library. Sort fields can be a + list of :class:`~plexapi.library.FilteringSort` objects, or a comma separated string. + Returns the validated comma separated sort fields string. + """ + if isinstance(sort, str): + sort = sort.split(',') + + if not isinstance(sort, (list, tuple)): + sort = [sort] + + validatedSorts = [] + for _sort in sort: + validatedSorts.append(self._validateSortField(_sort, libtype)) + + return ','.join(validatedSorts) + + def _validateSortField(self, sort, libtype=None): + """ Validates a filter sort field is available for the library. A sort field can be a + :class:`~plexapi.library.FilteringSort` object, or a string. + Returns the validated sort field string. + """ + if isinstance(sort, FilteringSort): + return '%s.%s:%s' % (libtype or self.TYPE, sort.key, sort.defaultDirection) + + match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+):?([a-zA-Z]*)', sort.strip()) + if not match: + raise BadRequest('Invalid filter sort: %s' % sort) + _libtype, sortField, sortDir = match.groups() + libtype = _libtype or libtype or self.TYPE + + try: + filterSort = next(f for f in self.listSorts(libtype) if f.key == sortField) + except StopIteration: + availableSorts = [f.key for f in self.listSorts(libtype)] + raise NotFound('Unknown sort field "%s" for libtype "%s". ' + 'Available sort fields: %s' + % (sortField, libtype, availableSorts)) from None + + sortField = libtype + '.' + filterSort.key + + availableDirections = ['', 'asc', 'desc', 'nullsLast'] + if sortDir not in availableDirections: + raise NotFound('Unknown sort direction "%s". ' + 'Available sort directions: %s' + % (sortDir, availableDirections)) + + return '%s:%s' % (sortField, sortDir) if sortDir else sortField + + def _validateAdvancedSearch(self, filters, libtype): + """ Validates an advanced search filter dictionary. + Returns the list of validated URL encoded parameter strings for the advanced search. + """ + if not isinstance(filters, dict): + raise BadRequest('Filters must be a dictionary.') + + validatedFilters = [] + + for field, values in list(filters.items()): + if field.lower() in {'and', 'or'}: + if len(list(filters.items())) > 1: + raise BadRequest('Multiple keys in the same dictionary with and/or is not allowed.') + if not isinstance(values, list): + raise BadRequest('Value for and/or keys must be a list of dictionaries.') + + validatedFilters.append('push=1') + + for value in values: + validatedFilters.extend(self._validateAdvancedSearch(value, libtype)) + validatedFilters.append('%s=1' % field.lower()) + + del validatedFilters[-1] + validatedFilters.append('pop=1') + + else: + validatedFilters.append(self._validateFilterField(field, values, libtype)) + + return validatedFilters + + def _buildSearchKey(self, title=None, sort=None, libtype=None, limit=None, filters=None, returnKwargs=False, **kwargs): + """ Returns the validated and formatted search query API key + (``/library/sections/<sectionKey>/all?<params>``). + """ + args = {} + filter_args = [] + + args['includeGuids'] = int(bool(kwargs.pop('includeGuids', True))) + for field, values in list(kwargs.items()): + if field.split('__')[-1] not in OPERATORS: + filter_args.append(self._validateFilterField(field, values, libtype)) + del kwargs[field] + if title is not None: + if isinstance(title, (list, tuple)): + filter_args.append(self._validateFilterField('title', title, libtype)) + else: + args['title'] = title + if filters is not None: + filter_args.extend(self._validateAdvancedSearch(filters, libtype)) + if sort is not None: + args['sort'] = self._validateSortFields(sort, libtype) + if libtype is not None: + args['type'] = utils.searchType(libtype) + if limit is not None: + args['limit'] = limit + + joined_args = utils.joinArgs(args).lstrip('?') + joined_filter_args = '&'.join(filter_args) if filter_args else '' + params = '&'.join([joined_args, joined_filter_args]).strip('&') + key = '/library/sections/%s/all?%s' % (self.key, params) + + if returnKwargs: + return key, kwargs + return key + + def hubSearch(self, query, mediatype=None, limit=None): + """ Returns the hub search results for this library. See :func:`plexapi.server.PlexServer.search` + for details and parameters. + """ + return self._server.search(query, mediatype, limit, sectionId=self.key) + + def search(self, title=None, sort=None, maxresults=None, libtype=None, + container_start=0, container_size=X_PLEX_CONTAINER_SIZE, limit=None, filters=None, **kwargs): + """ Search the library. The http requests will be batched in container_size. If you are only looking for the + first <num> results, it would be wise to set the maxresults option to that amount so the search doesn't iterate + over all results on the server. + + Parameters: + title (str, optional): General string query to search for. Partial string matches are allowed. + sort (:class:`~plexapi.library.FilteringSort` or str or list, optional): A field to sort the results. + See the details below for more info. + maxresults (int, optional): Only return the specified number of results. + libtype (str, optional): Return results of a specific type (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection) (e.g. ``libtype='episode'`` will only + return :class:`~plexapi.video.Episode` objects) + container_start (int, optional): Default 0. + container_size (int, optional): Default X_PLEX_CONTAINER_SIZE in your config file. + limit (int, optional): Limit the number of results from the filter. + filters (dict, optional): A dictionary of advanced filters. See the details below for more info. + **kwargs (dict): Additional custom filters to apply to the search results. + See the details below for more info. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid. + :exc:`~plexapi.exceptions.NotFound`: When applying an unknown sort or filter. + + **Sorting Results** + + The search results can be sorted by including the ``sort`` parameter. + + * See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields. + + The ``sort`` parameter can be a :class:`~plexapi.library.FilteringSort` object or a sort string in the + format ``field:dir``. The sort direction ``dir`` can be ``asc``, ``desc``, or ``nullsLast``. Omitting the + sort direction or using a :class:`~plexapi.library.FilteringSort` object will sort the results in the default + direction of the field. Multi-sorting on multiple fields can be achieved by using a comma separated list of + sort strings, or a list of :class:`~plexapi.library.FilteringSort` object or strings. + + Examples: + + .. code-block:: python + + library.search(sort="titleSort:desc") # Sort title in descending order + library.search(sort="titleSort") # Sort title in the default order + # Multi-sort by year in descending order, then by audience rating in descending order + library.search(sort="year:desc,audienceRating:desc") + library.search(sort=["year:desc", "audienceRating:desc"]) + + **Using Plex Filters** + + Any of the available custom filters can be applied to the search results + (`screenshot <../_static/images/LibrarySection.search.png>`__). + + * See :func:`~plexapi.library.LibrarySection.listFields` to get a list of all available fields. + * See :func:`~plexapi.library.LibrarySection.listOperators` to get a list of all available operators. + * See :func:`~plexapi.library.LibrarySection.listFilterChoices` to get a list of all available filter values. + + The following filter fields are just some examples of the possible filters. The list is not exaustive, + and not all filters apply to all library types. + + * **actor** (:class:`~plexapi.media.MediaTag`): Search for the name of an actor. + * **addedAt** (*datetime*): Search for items added before or after a date. See operators below. + * **audioLanguage** (*str*): Search for a specific audio language (3 character code, e.g. jpn). + * **collection** (:class:`~plexapi.media.MediaTag`): Search for the name of a collection. + * **contentRating** (:class:`~plexapi.media.MediaTag`): Search for a specific content rating. + * **country** (:class:`~plexapi.media.MediaTag`): Search for the name of a country. + * **decade** (*int*): Search for a specific decade (e.g. 2000). + * **director** (:class:`~plexapi.media.MediaTag`): Search for the name of a director. + * **duplicate** (*bool*) Search for duplicate items. + * **genre** (:class:`~plexapi.media.MediaTag`): Search for a specific genre. + * **hdr** (*bool*): Search for HDR items. + * **inProgress** (*bool*): Search for in progress items. + * **label** (:class:`~plexapi.media.MediaTag`): Search for a specific label. + * **lastViewedAt** (*datetime*): Search for items watched before or after a date. See operators below. + * **mood** (:class:`~plexapi.media.MediaTag`): Search for a specific mood. + * **producer** (:class:`~plexapi.media.MediaTag`): Search for the name of a producer. + * **resolution** (*str*): Search for a specific resolution (e.g. 1080). + * **studio** (*str*): Search for the name of a studio. + * **style** (:class:`~plexapi.media.MediaTag`): Search for a specific style. + * **subtitleLanguage** (*str*): Search for a specific subtitle language (3 character code, e.g. eng) + * **unmatched** (*bool*): Search for unmatched items. + * **unwatched** (*bool*): Search for unwatched items. + * **userRating** (*int*): Search for items with a specific user rating. + * **writer** (:class:`~plexapi.media.MediaTag`): Search for the name of a writer. + * **year** (*int*): Search for a specific year. + + Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object, + :class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*), + or the exact id :attr:`MediaTag.id` (*int*). + + Date type filter values can be a ``datetime`` object, a relative date using a one of the + available date suffixes (e.g. ``30d``) (*str*), or a date in ``YYYY-MM-DD`` (*str*) format. + + Relative date suffixes: + + * ``s``: ``seconds`` + * ``m``: ``minutes`` + * ``h``: ``hours`` + * ``d``: ``days`` + * ``w``: ``weeks`` + * ``mon``: ``months`` + * ``y``: ``years`` + + Multiple values can be ``OR`` together by providing a list of values. + + Examples: + + .. code-block:: python + + library.search(unwatched=True, year=2020, resolution="4k") + library.search(actor="Arnold Schwarzenegger", decade=1990) + library.search(contentRating="TV-G", genre="animation") + library.search(genre=["animation", "comedy"]) # Genre is animation OR comedy + library.search(studio=["Disney", "Pixar"]) # Studio contains Disney OR Pixar + + **Using a** ``libtype`` **Prefix** + + Some filters may be prefixed by the ``libtype`` separated by a ``.`` (e.g. ``show.collection``, + ``episode.title``, ``artist.style``, ``album.genre``, ``track.userRating``, etc.). This should not be + confused with the ``libtype`` parameter. If no ``libtype`` prefix is provided, then the default library + type is assumed. For example, in a TV show library ``viewCout`` is assumed to be ``show.viewCount``. + If you want to filter using episode view count then you must specify ``episode.viewCount`` explicitly. + In addition, if the filter does not exist for the default library type it will fallback to the most + specific ``libtype`` available. For example, ``show.unwatched`` does not exists so it will fallback to + ``episode.unwatched``. The ``libtype`` prefix cannot be included directly in the function parameters so + the filters must be provided as a filters dictionary. + + Examples: + + .. code-block:: python + + library.search(filters={"show.collection": "Documentary", "episode.inProgress": True}) + library.search(filters={"artist.genre": "pop", "album.decade": 2000}) + + # The following three options are identical and will return Episode objects + showLibrary.search(title="Winter is Coming", libtype='episode') + showLibrary.search(libtype='episode', filters={"episode.title": "Winter is Coming"}) + showLibrary.searchEpisodes(title="Winter is Coming") + + # The following will search for the episode title but return Show objects + showLibrary.search(filters={"episode.title": "Winter is Coming"}) + + # The following will fallback to episode.unwatched + showLibrary.search(unwatched=True) + + **Using Plex Operators** + + Operators can be appended to the filter field to narrow down results with more granularity. If no + operator is specified, the default operator is assumed to be ``=``. The following is a list of + possible operators depending on the data type of the filter being applied. A special ``&`` operator + can also be used to ``AND`` together a list of values. + + Type: :class:`~plexapi.media.MediaTag` or *subtitleLanguage* or *audioLanguage* + + * ``=``: ``is`` + * ``!=``: ``is not`` + + Type: *int* + + * ``=``: ``is`` + * ``!=``: ``is not`` + * ``>>=``: ``is greater than`` + * ``<<=``: ``is less than`` + + Type: *str* + + * ``=``: ``contains`` + * ``!=``: ``does not contain`` + * ``==``: ``is`` + * ``!==``: ``is not`` + * ``<=``: ``begins with`` + * ``>=``: ``ends with`` + + Type: *bool* + + * ``=``: ``is true`` + * ``!=``: ``is false`` + + Type: *datetime* + + * ``<<=``: ``is before`` + * ``>>=``: ``is after`` + + Type: *resolution* + + * ``=``: ``is`` + + Operators cannot be included directly in the function parameters so the filters + must be provided as a filters dictionary. The trailing ``=`` on the operator may be excluded. + + Examples: + + .. code-block:: python + + # Genre is horror AND thriller + library.search(filters={"genre&": ["horror", "thriller"]}) + + # Director is not Steven Spielberg + library.search(filters={"director!": "Steven Spielberg"}) + + # Title starts with Marvel and added before 2021-01-01 + library.search(filters={"title<": "Marvel", "addedAt<<": "2021-01-01"}) + + # Added in the last 30 days using relative dates + library.search(filters={"addedAt>>": "30d"}) + + # Collection is James Bond and user rating is greater than 8 + library.search(filters={"collection": "James Bond", "userRating>>": 8}) + + **Using Advanced Filters** + + Any of the Plex filters described above can be combined into a single ``filters`` dictionary that mimics + the advanced filters used in Plex Web with a tree of ``and``/``or`` branches. Each level of the tree must + start with ``and`` (Match all of the following) or ``or`` (Match any of the following) as the dictionary + key, and a list of dictionaries with the desired filters as the dictionary value. + + The following example matches `this <../_static/images/LibrarySection.search_filters.png>`__ advanced filter + in Plex Web. + + Examples: + + .. code-block:: python + + advancedFilters = { + 'and': [ # Match all of the following in this list + { + 'or': [ # Match any of the following in this list + {'title': 'elephant'}, + {'title': 'bunny'} + ] + }, + {'year>>': 1990}, + {'unwatched': True} + ] + } + library.search(filters=advancedFilters) + + **Using PlexAPI Operators** + + For even more advanced filtering which cannot be achieved in Plex, the PlexAPI operators can be applied + to any XML attribute. See :func:`plexapi.base.PlexObject.fetchItems` for a list of operators and how they + are used. Note that using the Plex filters above will be faster since the filters are applied by the Plex + server before the results are returned to PlexAPI. Using the PlexAPI operators requires the Plex server + to return *all* results to allow PlexAPI to do the filtering. The Plex filters and the PlexAPI operators + can be used in conjunction with each other. + + Examples: + + .. code-block:: python + + library.search(summary__icontains="Christmas") + library.search(duration__gt=7200000) + library.search(audienceRating__lte=6.0, audienceRatingImage__startswith="rottentomatoes://") + library.search(media__videoCodec__exact="h265") + library.search(genre="holiday", viewCount__gte=3) + + """ + key, kwargs = self._buildSearchKey( + title=title, sort=sort, libtype=libtype, limit=limit, filters=filters, returnKwargs=True, **kwargs) + return self._search(key, maxresults, container_start, container_size, **kwargs) + + def _search(self, key, maxresults, container_start, container_size, **kwargs): + """ Perform the actual library search and return the results. """ + results = [] + subresults = [] + offset = container_start + + if maxresults is not None: + container_size = min(container_size, maxresults) + + while True: + subresults = self.fetchItems(key, container_start=container_start, + container_size=container_size, **kwargs) + if not len(subresults): + if offset > self._totalViewSize: + log.info("container_start is higher than the number of items in the library") + + results.extend(subresults) + + # self._totalViewSize is not used as a condition in the while loop as + # this require a additional http request. + # self._totalViewSize is updated from self.fetchItems + wanted_number_of_items = self._totalViewSize - offset + if maxresults is not None: + wanted_number_of_items = min(maxresults, wanted_number_of_items) + container_size = min(container_size, maxresults - len(results)) + + if wanted_number_of_items <= len(results): + break + + container_start += container_size + + if container_start > self._totalViewSize: + break + + return results + + def _locations(self): + """ Returns a list of :class:`~plexapi.library.Location` objects + """ + return self.findItems(self._data, Location) + + def sync(self, policy, mediaSettings, client=None, clientId=None, title=None, sort=None, libtype=None, + **kwargs): + """ Add current library section as sync item for specified device. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting + and :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. + + Parameters: + policy (:class:`~plexapi.sync.Policy`): policy of syncing the media (how many items to sync and process + watched media or not), generated automatically when method + called on specific LibrarySection object. + mediaSettings (:class:`~plexapi.sync.MediaSettings`): Transcoding settings used for the media, generated + automatically when method called on specific + LibrarySection object. + client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`~plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`. + title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current media. + sort (str): formatted as `column:dir`; column can be any of {`addedAt`, `originallyAvailableAt`, + `lastViewedAt`, `titleSort`, `rating`, `mediaHeight`, `duration`}. dir can be `asc` or + `desc`. + libtype (str): Filter results to a specific libtype (`movie`, `show`, `episode`, `artist`, `album`, + `track`). + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When the library is not allowed to sync. + :exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid. + :exc:`~plexapi.exceptions.NotFound`: When applying an unknown sort or filter. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import Policy, MediaSettings, VIDEO_QUALITY_3_MBPS_720p + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Movies') + policy = Policy('count', unwatched=True, value=1) + media_settings = MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p) + section.sync(target, policy, media_settings, title='Next best movie', sort='rating:desc') + + """ + from plexapi.sync import SyncItem + + if not self.allowSync: + raise BadRequest('The requested library is not allowed to sync') + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self.title + sync_item.rootTitle = self.title + sync_item.contentType = self.CONTENT_TYPE + sync_item.metadataType = self.METADATA_TYPE + sync_item.machineIdentifier = self._server.machineIdentifier + + key = self._buildSearchKey(title=title, sort=sort, libtype=libtype, **kwargs) + + sync_item.location = 'library://%s/directory/%s' % (self.uuid, quote_plus(key)) + sync_item.policy = policy + sync_item.mediaSettings = mediaSettings + + return myplex.sync(client=client, clientId=clientId, sync_item=sync_item) + + def history(self, maxresults=9999999, mindate=None): + """ Get Play History for this library Section for the owner. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1) + + def createCollection(self, title, items=None, smart=False, limit=None, + libtype=None, sort=None, filters=None, **kwargs): + """ Alias for :func:`~plexapi.server.PlexServer.createCollection` using this + :class:`~plexapi.library.LibrarySection`. + """ + return self._server.createCollection( + title, section=self, items=items, smart=smart, limit=limit, + libtype=libtype, sort=sort, filters=filters, **kwargs) + + def collection(self, title): + """ Returns the collection with the specified title. + + Parameters: + title (str): Title of the item to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unable to find collection. + """ + try: + return self.collections(title=title, title__iexact=title)[0] + except IndexError: + raise NotFound('Unable to find collection with title "%s".' % title) from None + + def collections(self, **kwargs): + """ Returns a list of collections from this library section. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting. + """ + return self.search(libtype='collection', **kwargs) + + def createPlaylist(self, title, items=None, smart=False, limit=None, + sort=None, filters=None, **kwargs): + """ Alias for :func:`~plexapi.server.PlexServer.createPlaylist` using this + :class:`~plexapi.library.LibrarySection`. + """ + return self._server.createPlaylist( + title, section=self, items=items, smart=smart, limit=limit, + sort=sort, filters=filters, **kwargs) + + def playlist(self, title): + """ Returns the playlist with the specified title. + + Parameters: + title (str): Title of the item to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unable to find playlist. + """ + try: + return self.playlists(title=title, title__iexact=title)[0] + except IndexError: + raise NotFound('Unable to find playlist with title "%s".' % title) from None + + def playlists(self, sort=None, **kwargs): + """ Returns a list of playlists from this library section. """ + return self._server.playlists( + playlistType=self.CONTENT_TYPE, sectionId=self.key, sort=sort, **kwargs) + + @deprecated('use "listFields" instead') + def filterFields(self, mediaType=None): + return self.listFields(libtype=mediaType) + + @deprecated('use "listFilterChoices" instead') + def listChoices(self, category, libtype=None, **kwargs): + return self.listFilterChoices(field=category, libtype=libtype) + + def getWebURL(self, base=None, tab=None, key=None): + """ Returns the Plex Web URL for the library. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + tab (str): The library tab (recommended, library, collections, playlists, timeline). + key (str): A hub key. + """ + params = {'source': self.key} + if tab is not None: + params['pivot'] = tab + if key is not None: + params['key'] = key + params['pageType'] = 'list' + return self._server._buildWebURL(base=base, **params) + + +class MovieSection(LibrarySection): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing movies. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'movie' + """ + TAG = 'Directory' + TYPE = 'movie' + METADATA_TYPE = 'movie' + CONTENT_TYPE = 'video' + + def searchMovies(self, **kwargs): + """ Search for a movie. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='movie', **kwargs) + + def recentlyAddedMovies(self, maxresults=50): + """ Returns a list of recently added movies from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='movie') + + def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): + """ Add current Movie library section as sync item for specified device. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and + :func:`~plexapi.library.LibrarySection.sync` for details on syncing libraries and possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`~plexapi.sync` module. + limit (int): maximum count of movies to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Movies') + section.sync(VIDEO_QUALITY_3_MBPS_720p, client=target, limit=1, unwatched=True, + title='Next best movie', sort='rating:desc') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) + kwargs['policy'] = Policy.create(limit, unwatched) + return super(MovieSection, self).sync(**kwargs) + + +class ShowSection(LibrarySection): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'show' + """ + TAG = 'Directory' + TYPE = 'show' + METADATA_TYPE = 'episode' + CONTENT_TYPE = 'video' + + def searchShows(self, **kwargs): + """ Search for a show. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='show', **kwargs) + + def searchSeasons(self, **kwargs): + """ Search for a season. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='season', **kwargs) + + def searchEpisodes(self, **kwargs): + """ Search for an episode. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='episode', **kwargs) + + def recentlyAddedShows(self, maxresults=50): + """ Returns a list of recently added shows from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='show') + + def recentlyAddedSeasons(self, maxresults=50): + """ Returns a list of recently added seasons from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='season') + + def recentlyAddedEpisodes(self, maxresults=50): + """ Returns a list of recently added episodes from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='episode') + + def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): + """ Add current Show library section as sync item for specified device. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and + :func:`~plexapi.library.LibrarySection.sync` for details on syncing libraries and possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`~plexapi.sync` module. + limit (int): maximum count of episodes to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('TV-Shows') + section.sync(VIDEO_QUALITY_3_MBPS_720p, client=target, limit=1, unwatched=True, + title='Next unwatched episode') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) + kwargs['policy'] = Policy.create(limit, unwatched) + return super(ShowSection, self).sync(**kwargs) + + +class MusicSection(LibrarySection): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing music artists. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'artist' + """ + TAG = 'Directory' + TYPE = 'artist' + METADATA_TYPE = 'track' + CONTENT_TYPE = 'audio' + + def albums(self): + """ Returns a list of :class:`~plexapi.audio.Album` objects in this section. """ + key = '/library/sections/%s/albums' % self.key + return self.fetchItems(key) + + def stations(self): + """ Returns a list of :class:`~plexapi.audio.Album` objects in this section. """ + key = '/hubs/sections/%s?includeStations=1' % self.key + return self.fetchItems(key, cls=Station) + + def searchArtists(self, **kwargs): + """ Search for an artist. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='artist', **kwargs) + + def searchAlbums(self, **kwargs): + """ Search for an album. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='album', **kwargs) + + def searchTracks(self, **kwargs): + """ Search for a track. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='track', **kwargs) + + def recentlyAddedArtists(self, maxresults=50): + """ Returns a list of recently added artists from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='artist') + + def recentlyAddedAlbums(self, maxresults=50): + """ Returns a list of recently added albums from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='album') + + def recentlyAddedTracks(self, maxresults=50): + """ Returns a list of recently added tracks from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='track') + + def sync(self, bitrate, limit=None, **kwargs): + """ Add current Music library section as sync item for specified device. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and + :func:`~plexapi.library.LibrarySection.sync` for details on syncing libraries and possible exceptions. + + Parameters: + bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the + module :mod:`~plexapi.sync`. + limit (int): maximum count of tracks to sync, unlimited if `None`. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import AUDIO_BITRATE_320_KBPS + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Music') + section.sync(AUDIO_BITRATE_320_KBPS, client=target, limit=100, sort='addedAt:desc', + title='New music') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createMusic(bitrate) + kwargs['policy'] = Policy.create(limit) + return super(MusicSection, self).sync(**kwargs) + + +class PhotoSection(LibrarySection): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing photos. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'photo' + """ + TAG = 'Directory' + TYPE = 'photo' + METADATA_TYPE = 'photo' + CONTENT_TYPE = 'photo' + + def all(self, libtype=None, **kwargs): + """ Returns a list of all items from this library section. + See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting. + """ + libtype = libtype or 'photoalbum' + return self.search(libtype=libtype, **kwargs) + + def collections(self, **kwargs): + raise NotImplementedError('Collections are not available for a Photo library.') + + def searchAlbums(self, title, **kwargs): + """ Search for a photo album. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='photoalbum', title=title, **kwargs) + + def searchPhotos(self, title, **kwargs): + """ Search for a photo. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='photo', title=title, **kwargs) + + def recentlyAddedAlbums(self, maxresults=50): + """ Returns a list of recently added photo albums from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + # Use search() instead of recentlyAdded() because libtype=None + return self.search(sort='addedAt:desc', maxresults=maxresults) + + def sync(self, resolution, limit=None, **kwargs): + """ Add current Music library section as sync item for specified device. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and + :func:`~plexapi.library.LibrarySection.sync` for details on syncing libraries and possible exceptions. + + Parameters: + resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the + module :mod:`~plexapi.sync`. + limit (int): maximum count of tracks to sync, unlimited if `None`. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import PHOTO_QUALITY_HIGH + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Photos') + section.sync(PHOTO_QUALITY_HIGH, client=target, limit=100, sort='addedAt:desc', + title='Fresh photos') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createPhoto(resolution) + kwargs['policy'] = Policy.create(limit) + return super(PhotoSection, self).sync(**kwargs) + + +@utils.registerPlexObject +class LibraryTimeline(PlexObject): + """Represents a LibrarySection timeline. + + Attributes: + TAG (str): 'LibraryTimeline' + size (int): Unknown + allowSync (bool): Unknown + art (str): Relative path to art image. + content (str): "secondary" + identifier (str): "com.plexapp.plugins.library" + latestEntryTime (int): Epoch timestamp + mediaTagPrefix (str): "/system/bundle/media/flags/" + mediaTagVersion (int): Unknown + thumb (str): Relative path to library thumb image. + title1 (str): Name of library section. + updateQueueSize (int): Number of items queued to update. + viewGroup (str): "secondary" + viewMode (int): Unknown + """ + TAG = 'LibraryTimeline' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.size = utils.cast(int, data.attrib.get('size')) + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.art = data.attrib.get('art') + self.content = data.attrib.get('content') + self.identifier = data.attrib.get('identifier') + self.latestEntryTime = utils.cast(int, data.attrib.get('latestEntryTime')) + self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') + self.mediaTagVersion = utils.cast(int, data.attrib.get('mediaTagVersion')) + self.thumb = data.attrib.get('thumb') + self.title1 = data.attrib.get('title1') + self.updateQueueSize = utils.cast(int, data.attrib.get('updateQueueSize')) + self.viewGroup = data.attrib.get('viewGroup') + self.viewMode = utils.cast(int, data.attrib.get('viewMode')) + + +@utils.registerPlexObject +class Location(PlexObject): + """ Represents a single library Location. + + Attributes: + TAG (str): 'Location' + id (int): Location path ID. + path (str): Path used for library.. + """ + TAG = 'Location' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.id = utils.cast(int, data.attrib.get('id')) + self.path = data.attrib.get('path') + + +@utils.registerPlexObject +class Hub(PlexObject): + """ Represents a single Hub (or category) in the PlexServer search. + + Attributes: + TAG (str): 'Hub' + context (str): The context of the hub. + hubKey (str): API URL for these specific hub items. + hubIdentifier (str): The identifier of the hub. + key (str): API URL for the hub. + more (bool): True if there are more items to load (call reload() to fetch all items). + size (int): The number of items in the hub. + style (str): The style of the hub. + title (str): The title of the hub. + type (str): The type of items in the hub. + """ + TAG = 'Hub' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.context = data.attrib.get('context') + self.hubKey = data.attrib.get('hubKey') + self.hubIdentifier = data.attrib.get('hubIdentifier') + self.items = self.findItems(data) + self.key = data.attrib.get('key') + self.more = utils.cast(bool, data.attrib.get('more')) + self.size = utils.cast(int, data.attrib.get('size')) + self.style = data.attrib.get('style') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self._section = None # cache for self.section + + def __len__(self): + return self.size + + def reload(self): + """ Reloads the hub to fetch all items in the hub. """ + if self.more and self.key: + self.items = self.fetchItems(self.key) + self.more = False + self.size = len(self.items) + + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` this hub belongs to. + """ + if self._section is None: + self._section = self._server.library.sectionByID(self.librarySectionID) + return self._section + + +class HubMediaTag(PlexObject): + """ Base class of hub media tag search results. + + Attributes: + count (int): The number of items where this tag is found. + filter (str): The URL filter for the tag. + id (int): The id of the tag. + key (str): API URL (/library/section/<librarySectionID>/all?<filter>). + librarySectionID (int): The library section ID where the tag is found. + librarySectionKey (str): API URL for the library section (/library/section/<librarySectionID>) + librarySectionTitle (str): The library title where the tag is found. + librarySectionType (int): The library type where the tag is found. + reason (str): The reason for the search result. + reasonID (int): The reason ID for the search result. + reasonTitle (str): The reason title for the search result. + type (str): The type of search result (tag). + tag (str): The title of the tag. + tagType (int): The type ID of the tag. + tagValue (int): The value of the tag. + thumb (str): The URL for the thumbnail of the tag (if available). + """ + TAG = 'Directory' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.count = utils.cast(int, data.attrib.get('count')) + self.filter = data.attrib.get('filter') + self.id = utils.cast(int, data.attrib.get('id')) + self.key = data.attrib.get('key') + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.librarySectionType = utils.cast(int, data.attrib.get('librarySectionType')) + self.reason = data.attrib.get('reason') + self.reasonID = utils.cast(int, data.attrib.get('reasonID')) + self.reasonTitle = data.attrib.get('reasonTitle') + self.type = data.attrib.get('type') + self.tag = data.attrib.get('tag') + self.tagType = utils.cast(int, data.attrib.get('tagType')) + self.tagValue = utils.cast(int, data.attrib.get('tagValue')) + self.thumb = data.attrib.get('thumb') + + def items(self, *args, **kwargs): + """ Return the list of items within this tag. """ + if not self.key: + raise BadRequest('Key is not defined for this tag: %s' % self.tag) + return self.fetchItems(self.key) + + +@utils.registerPlexObject +class Tag(HubMediaTag): + """ Represents a single Tag hub search media tag. + + Attributes: + TAGTYPE (int): 0 + """ + TAGTYPE = 0 + + +@utils.registerPlexObject +class Genre(HubMediaTag): + """ Represents a single Genre hub search media tag. + + Attributes: + TAGTYPE (int): 1 + """ + TAGTYPE = 1 + + +@utils.registerPlexObject +class Director(HubMediaTag): + """ Represents a single Director hub search media tag. + + Attributes: + TAGTYPE (int): 4 + """ + TAGTYPE = 4 + + +@utils.registerPlexObject +class Actor(HubMediaTag): + """ Represents a single Actor hub search media tag. + + Attributes: + TAGTYPE (int): 6 + """ + TAGTYPE = 6 + + +@utils.registerPlexObject +class AutoTag(HubMediaTag): + """ Represents a single AutoTag hub search media tag. + + Attributes: + TAGTYPE (int): 207 + """ + TAGTYPE = 207 + + +@utils.registerPlexObject +class Place(HubMediaTag): + """ Represents a single Place hub search media tag. + + Attributes: + TAGTYPE (int): 400 + """ + TAGTYPE = 400 + + +@utils.registerPlexObject +class Station(PlexObject): + """ Represents the Station area in the MusicSection. + + Attributes: + TITLE (str): 'Stations' + TYPE (str): 'station' + hubIdentifier (str): Unknown. + size (int): Number of items found. + title (str): Title of this Hub. + type (str): Type of items in the Hub. + more (str): Unknown. + style (str): Unknown + items (str): List of items in the Hub. + """ + TITLE = 'Stations' + TYPE = 'station' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.hubIdentifier = data.attrib.get('hubIdentifier') + self.size = utils.cast(int, data.attrib.get('size')) + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.more = data.attrib.get('more') + self.style = data.attrib.get('style') + self.items = self.findItems(data) + + def __len__(self): + return self.size + + +class FilteringType(PlexObject): + """ Represents a single filtering Type object for a library. + + Attributes: + TAG (str): 'Type' + active (bool): True if this filter type is currently active. + fields (List<:class:`~plexapi.library.FilteringField`>): List of field objects. + filters (List<:class:`~plexapi.library.FilteringFilter`>): List of filter objects. + key (str): The API URL path for the libtype filter. + sorts (List<:class:`~plexapi.library.FilteringSort`>): List of sort objects. + title (str): The title for the libtype filter. + type (str): The libtype for the filter. + """ + TAG = 'Type' + + def __repr__(self): + _type = self._clean(self.firstAttr('type')) + return '<%s>' % ':'.join([p for p in [self.__class__.__name__, _type] if p]) + + def _loadData(self, data): + self._data = data + self.active = utils.cast(bool, data.attrib.get('active', '0')) + self.fields = self.findItems(data, FilteringField) + self.filters = self.findItems(data, FilteringFilter) + self.key = data.attrib.get('key') + self.sorts = self.findItems(data, FilteringSort) + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + + # Add additional manual sorts and fields which are available + # but not exposed on the Plex server + self.sorts += self._manualSorts() + self.fields += self._manualFields() + + def _manualSorts(self): + """ Manually add additional sorts which are available + but not exposed on the Plex server. + """ + # Sorts: key, dir, title + additionalSorts = [ + ('guid', 'asc', 'Guid'), + ('id', 'asc', 'Rating Key'), + ('index', 'asc', '%s Number' % self.type.capitalize()), + ('summary', 'asc', 'Summary'), + ('tagline', 'asc', 'Tagline'), + ('updatedAt', 'asc', 'Date Updated') + ] + + if self.type == 'season': + additionalSorts.extend([ + ('titleSort', 'asc', 'Title') + ]) + elif self.type == 'track': + # Don't know what this is but it is valid + additionalSorts.extend([ + ('absoluteIndex', 'asc', 'Absolute Index') + ]) + elif self.type == 'photo': + additionalSorts.extend([ + ('viewUpdatedAt', 'desc', 'View Updated At') + ]) + elif self.type == 'collection': + additionalSorts.extend([ + ('addedAt', 'asc', 'Date Added') + ]) + + manualSorts = [] + for sortField, sortDir, sortTitle in additionalSorts: + sortXML = ('<Sort defaultDirection="%s" descKey="%s:desc" key="%s" title="%s" />' + % (sortDir, sortField, sortField, sortTitle)) + manualSorts.append(self._manuallyLoadXML(sortXML, FilteringSort)) + + return manualSorts + + def _manualFields(self): + """ Manually add additional fields which are available + but not exposed on the Plex server. + """ + # Fields: key, type, title + additionalFields = [ + ('guid', 'string', 'Guid'), + ('id', 'integer', 'Rating Key'), + ('index', 'integer', '%s Number' % self.type.capitalize()), + ('lastRatedAt', 'date', '%s Last Rated' % self.type.capitalize()), + ('updatedAt', 'date', 'Date Updated') + ] + + if self.type == 'movie': + additionalFields.extend([ + ('audienceRating', 'integer', 'Audience Rating'), + ('rating', 'integer', 'Critic Rating'), + ('viewOffset', 'integer', 'View Offset') + ]) + elif self.type == 'show': + additionalFields.extend([ + ('audienceRating', 'integer', 'Audience Rating'), + ('originallyAvailableAt', 'date', 'Show Release Date'), + ('rating', 'integer', 'Critic Rating'), + ('unviewedLeafCount', 'integer', 'Episode Unplayed Count') + ]) + elif self.type == 'season': + additionalFields.extend([ + ('addedAt', 'date', 'Date Season Added'), + ('unviewedLeafCount', 'integer', 'Episode Unplayed Count'), + ('year', 'integer', 'Season Year') + ]) + elif self.type == 'episode': + additionalFields.extend([ + ('audienceRating', 'integer', 'Audience Rating'), + ('duration', 'integer', 'Duration'), + ('rating', 'integer', 'Critic Rating'), + ('viewOffset', 'integer', 'View Offset') + ]) + elif self.type == 'track': + additionalFields.extend([ + ('duration', 'integer', 'Duration'), + ('viewOffset', 'integer', 'View Offset') + ]) + elif self.type == 'collection': + additionalFields.extend([ + ('addedAt', 'date', 'Date Added') + ]) + + prefix = '' if self.type == 'movie' else self.type + '.' + + manualFields = [] + for field, fieldType, fieldTitle in additionalFields: + fieldXML = ('<Field key="%s%s" title="%s" type="%s"/>' + % (prefix, field, fieldTitle, fieldType)) + manualFields.append(self._manuallyLoadXML(fieldXML, FilteringField)) + + return manualFields + + +class FilteringFilter(PlexObject): + """ Represents a single Filter object for a :class:`~plexapi.library.FilteringType`. + + Attributes: + TAG (str): 'Filter' + filter (str): The key for the filter. + filterType (str): The :class:`~plexapi.library.FilteringFieldType` type (string, boolean, integer, date, etc). + key (str): The API URL path for the filter. + title (str): The title of the filter. + type (str): 'filter' + """ + TAG = 'Filter' + + def _loadData(self, data): + self._data = data + self.filter = data.attrib.get('filter') + self.filterType = data.attrib.get('filterType') + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + + +class FilteringSort(PlexObject): + """ Represents a single Sort object for a :class:`~plexapi.library.FilteringType`. + + Attributes: + TAG (str): 'Sort' + active (bool): True if the sort is currently active. + activeDirection (str): The currently active sorting direction. + default (str): The currently active default sorting direction. + defaultDirection (str): The default sorting direction. + descKey (str): The URL key for sorting with desc. + firstCharacterKey (str): API URL path for first character endpoint. + key (str): The URL key for the sorting. + title (str): The title of the sorting. + """ + TAG = 'Sort' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.active = utils.cast(bool, data.attrib.get('active', '0')) + self.activeDirection = data.attrib.get('activeDirection') + self.default = data.attrib.get('default') + self.defaultDirection = data.attrib.get('defaultDirection') + self.descKey = data.attrib.get('descKey') + self.firstCharacterKey = data.attrib.get('firstCharacterKey') + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + + +class FilteringField(PlexObject): + """ Represents a single Field object for a :class:`~plexapi.library.FilteringType`. + + Attributes: + TAG (str): 'Field' + key (str): The URL key for the filter field. + title (str): The title of the filter field. + type (str): The :class:`~plexapi.library.FilteringFieldType` type (string, boolean, integer, date, etc). + subType (str): The subtype of the filter (decade, rating, etc). + """ + TAG = 'Field' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.subType = data.attrib.get('subType') + + +class FilteringFieldType(PlexObject): + """ Represents a single FieldType for library filtering. + + Attributes: + TAG (str): 'FieldType' + type (str): The filtering data type (string, boolean, integer, date, etc). + operators (List<:class:`~plexapi.library.FilteringOperator`>): List of operator objects. + """ + TAG = 'FieldType' + + def __repr__(self): + _type = self._clean(self.firstAttr('type')) + return '<%s>' % ':'.join([p for p in [self.__class__.__name__, _type] if p]) + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.type = data.attrib.get('type') + self.operators = self.findItems(data, FilteringOperator) + + +class FilteringOperator(PlexObject): + """ Represents an single Operator for a :class:`~plexapi.library.FilteringFieldType`. + + Attributes: + TAG (str): 'Operator' + key (str): The URL key for the operator. + title (str): The title of the operator. + """ + TAG = 'Operator' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + + +class FilterChoice(PlexObject): + """ Represents a single FilterChoice object. + These objects are gathered when using filters while searching for library items and is the + object returned in the result set of :func:`~plexapi.library.LibrarySection.listFilterChoices`. + + Attributes: + TAG (str): 'Directory' + fastKey (str): API URL path to quickly list all items with this filter choice. + (/library/sections/<section>/all?genre=<key>) + key (str): The id value of this filter choice. + thumb (str): Thumbnail URL for the filter choice. + title (str): The title of the filter choice. + type (str): The filter type (genre, contentRating, etc). + """ + TAG = 'Directory' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.fastKey = data.attrib.get('fastKey') + self.key = data.attrib.get('key') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + + +class Folder(PlexObject): + """ Represents a Folder inside a library. + + Attributes: + key (str): Url key for folder. + title (str): Title of folder. + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + + def subfolders(self): + """ Returns a list of available :class:`~plexapi.library.Folder` for this folder. + Continue down subfolders until a mediaType is found. + """ + if self.key.startswith('/library/metadata'): + return self.fetchItems(self.key) + else: + return self.fetchItems(self.key, Folder) + + def allSubfolders(self): + """ Returns a list of all available :class:`~plexapi.library.Folder` for this folder. + Only returns :class:`~plexapi.library.Folder`. + """ + folders = [] + for folder in self.subfolders(): + if not folder.key.startswith('/library/metadata'): + folders.append(folder) + while True: + for subfolder in folder.subfolders(): + if not subfolder.key.startswith('/library/metadata'): + folders.append(subfolder) + continue + break + return folders + + +class FirstCharacter(PlexObject): + """ Represents a First Character element from a library. + + Attributes: + key (str): Url key for character. + size (str): Total amount of library items starting with this character. + title (str): Character (#, !, A, B, C, ...). + """ + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.key = data.attrib.get('key') + self.size = data.attrib.get('size') + self.title = data.attrib.get('title') + + +@utils.registerPlexObject +class Path(PlexObject): + """ Represents a single directory Path. + + Attributes: + TAG (str): 'Path' + + home (bool): True if the path is the home directory + key (str): API URL (/services/browse/<base64path>) + network (bool): True if path is a network location + path (str): Full path to folder + title (str): Folder name + """ + TAG = 'Path' + + def _loadData(self, data): + self.home = utils.cast(bool, data.attrib.get('home')) + self.key = data.attrib.get('key') + self.network = utils.cast(bool, data.attrib.get('network')) + self.path = data.attrib.get('path') + self.title = data.attrib.get('title') + + def browse(self, includeFiles=True): + """ Alias for :func:`~plexapi.server.PlexServer.browse`. """ + return self._server.browse(self, includeFiles) + + def walk(self): + """ Alias for :func:`~plexapi.server.PlexServer.walk`. """ + for path, paths, files in self._server.walk(self): + yield path, paths, files + + +@utils.registerPlexObject +class File(PlexObject): + """ Represents a single File. + + Attributes: + TAG (str): 'File' + + key (str): API URL (/services/browse/<base64path>) + path (str): Full path to file + title (str): File name + """ + TAG = 'File' + + def _loadData(self, data): + self.key = data.attrib.get('key') + self.path = data.attrib.get('path') + self.title = data.attrib.get('title') diff --git a/service.plexskipintro/plexapi/media.py b/service.plexskipintro/plexapi/media.py new file mode 100644 index 0000000000..95385c4a2f --- /dev/null +++ b/service.plexskipintro/plexapi/media.py @@ -0,0 +1,1088 @@ +# -*- coding: utf-8 -*- + +import xml +from urllib.parse import quote_plus + +from plexapi import log, settings, utils +from plexapi.base import PlexObject +from plexapi.exceptions import BadRequest + + +@utils.registerPlexObject +class Media(PlexObject): + """ Container object for all MediaPart objects. Provides useful data about the + video or audio this media belong to such as video framerate, resolution, etc. + + Attributes: + TAG (str): 'Media' + aspectRatio (float): The aspect ratio of the media (ex: 2.35). + audioChannels (int): The number of audio channels of the media (ex: 6). + audioCodec (str): The audio codec of the media (ex: ac3). + audioProfile (str): The audio profile of the media (ex: dts). + bitrate (int): The bitrate of the media (ex: 1624). + container (str): The container of the media (ex: avi). + duration (int): The duration of the media in milliseconds (ex: 6990483). + height (int): The height of the media in pixels (ex: 256). + id (int): The unique ID for this media on the server. + has64bitOffsets (bool): True if video has 64 bit offsets. + optimizedForStreaming (bool): True if video is optimized for streaming. + parts (List<:class:`~plexapi.media.MediaPart`>): List of media part objects. + proxyType (int): Equals 42 for optimized versions. + target (str): The media version target name. + title (str): The title of the media. + videoCodec (str): The video codec of the media (ex: ac3). + videoFrameRate (str): The video frame rate of the media (ex: 24p). + videoProfile (str): The video profile of the media (ex: high). + videoResolution (str): The video resolution of the media (ex: sd). + width (int): The width of the video in pixels (ex: 608). + + <Photo_only_attributes>: The following attributes are only available for photos. + + * aperture (str): The apeture used to take the photo. + * exposure (str): The exposure used to take the photo. + * iso (int): The iso used to take the photo. + * lens (str): The lens used to take the photo. + * make (str): The make of the camera used to take the photo. + * model (str): The model of the camera used to take the photo. + """ + TAG = 'Media' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.aspectRatio = utils.cast(float, data.attrib.get('aspectRatio')) + self.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) + self.audioCodec = data.attrib.get('audioCodec') + self.audioProfile = data.attrib.get('audioProfile') + self.bitrate = utils.cast(int, data.attrib.get('bitrate')) + self.container = data.attrib.get('container') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.height = utils.cast(int, data.attrib.get('height')) + self.id = utils.cast(int, data.attrib.get('id')) + self.has64bitOffsets = utils.cast(bool, data.attrib.get('has64bitOffsets')) + self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming')) + self.parts = self.findItems(data, MediaPart) + self.proxyType = utils.cast(int, data.attrib.get('proxyType')) + self.target = data.attrib.get('target') + self.title = data.attrib.get('title') + self.videoCodec = data.attrib.get('videoCodec') + self.videoFrameRate = data.attrib.get('videoFrameRate') + self.videoProfile = data.attrib.get('videoProfile') + self.videoResolution = data.attrib.get('videoResolution') + self.width = utils.cast(int, data.attrib.get('width')) + + if self._isChildOf(etag='Photo'): + self.aperture = data.attrib.get('aperture') + self.exposure = data.attrib.get('exposure') + self.iso = utils.cast(int, data.attrib.get('iso')) + self.lens = data.attrib.get('lens') + self.make = data.attrib.get('make') + self.model = data.attrib.get('model') + + parent = self._parent() + self._parentKey = parent.key + + @property + def isOptimizedVersion(self): + """ Returns True if the media is a Plex optimized version. """ + return self.proxyType == utils.SEARCHTYPES['optimizedVersion'] + + def delete(self): + part = '%s/media/%s' % (self._parentKey, self.id) + try: + return self._server.query(part, method=self._server._session.delete) + except BadRequest: + log.error("Failed to delete %s. This could be because you havn't allowed " + "items to be deleted" % part) + raise + + +@utils.registerPlexObject +class MediaPart(PlexObject): + """ Represents a single media part (often a single file) for the media this belongs to. + + Attributes: + TAG (str): 'Part' + accessible (bool): True if the file is accessible. + audioProfile (str): The audio profile of the file. + container (str): The container type of the file (ex: avi). + decision (str): Unknown. + deepAnalysisVersion (int): The Plex deep analysis version for the file. + duration (int): The duration of the file in milliseconds. + exists (bool): True if the file exists. + file (str): The path to this file on disk (ex: /media/Movies/Cars (2006)/Cars (2006).mkv) + has64bitOffsets (bool): True if the file has 64 bit offsets. + hasThumbnail (bool): True if the file (track) has an embedded thumbnail. + id (int): The unique ID for this media part on the server. + indexes (str, None): sd if the file has generated preview (BIF) thumbnails. + key (str): API URL (ex: /library/parts/46618/1389985872/file.mkv). + optimizedForStreaming (bool): True if the file is optimized for streaming. + packetLength (int): The packet length of the file. + requiredBandwidths (str): The required bandwidths to stream the file. + size (int): The size of the file in bytes (ex: 733884416). + streams (List<:class:`~plexapi.media.MediaPartStream`>): List of stream objects. + syncItemId (int): The unique ID for this media part if it is synced. + syncState (str): The sync state for this media part. + videoProfile (str): The video profile of the file. + """ + TAG = 'Part' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.accessible = utils.cast(bool, data.attrib.get('accessible')) + self.audioProfile = data.attrib.get('audioProfile') + self.container = data.attrib.get('container') + self.decision = data.attrib.get('decision') + self.deepAnalysisVersion = utils.cast(int, data.attrib.get('deepAnalysisVersion')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.exists = utils.cast(bool, data.attrib.get('exists')) + self.file = data.attrib.get('file') + self.has64bitOffsets = utils.cast(bool, data.attrib.get('has64bitOffsets')) + self.hasThumbnail = utils.cast(bool, data.attrib.get('hasThumbnail')) + self.id = utils.cast(int, data.attrib.get('id')) + self.indexes = data.attrib.get('indexes') + self.key = data.attrib.get('key') + self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming')) + self.packetLength = utils.cast(int, data.attrib.get('packetLength')) + self.requiredBandwidths = data.attrib.get('requiredBandwidths') + self.size = utils.cast(int, data.attrib.get('size')) + self.streams = self._buildStreams(data) + self.syncItemId = utils.cast(int, data.attrib.get('syncItemId')) + self.syncState = data.attrib.get('syncState') + self.videoProfile = data.attrib.get('videoProfile') + + def _buildStreams(self, data): + streams = [] + for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream): + items = self.findItems(data, cls, streamType=cls.STREAMTYPE) + streams.extend(items) + return streams + + @property + def hasPreviewThumbnails(self): + """ Returns True if the media part has generated preview (BIF) thumbnails. """ + return self.indexes == 'sd' + + def videoStreams(self): + """ Returns a list of :class:`~plexapi.media.VideoStream` objects in this MediaPart. """ + return [stream for stream in self.streams if isinstance(stream, VideoStream)] + + def audioStreams(self): + """ Returns a list of :class:`~plexapi.media.AudioStream` objects in this MediaPart. """ + return [stream for stream in self.streams if isinstance(stream, AudioStream)] + + def subtitleStreams(self): + """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """ + return [stream for stream in self.streams if isinstance(stream, SubtitleStream)] + + def lyricStreams(self): + """ Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """ + return [stream for stream in self.streams if isinstance(stream, LyricStream)] + + def setDefaultAudioStream(self, stream): + """ Set the default :class:`~plexapi.media.AudioStream` for this MediaPart. + + Parameters: + stream (:class:`~plexapi.media.AudioStream`): AudioStream to set as default + """ + if isinstance(stream, AudioStream): + key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream.id) + else: + key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream) + self._server.query(key, method=self._server._session.put) + + def setDefaultSubtitleStream(self, stream): + """ Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart. + + Parameters: + stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default. + """ + if isinstance(stream, SubtitleStream): + key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream.id) + else: + key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream) + self._server.query(key, method=self._server._session.put) + + def resetDefaultSubtitleStream(self): + """ Set default subtitle of this MediaPart to 'none'. """ + key = "/library/parts/%d?subtitleStreamID=0&allParts=1" % (self.id) + self._server.query(key, method=self._server._session.put) + + +class MediaPartStream(PlexObject): + """ Base class for media streams. These consist of video, audio, subtitles, and lyrics. + + Attributes: + bitrate (int): The bitrate of the stream. + codec (str): The codec of the stream (ex: srt, ac3, mpeg4). + default (bool): True if this is the default stream. + displayTitle (str): The display title of the stream. + extendedDisplayTitle (str): The extended display title of the stream. + key (str): API URL (/library/streams/<id>) + id (int): The unique ID for this stream on the server. + index (int): The index of the stream. + language (str): The language of the stream (ex: English, ไทย). + languageCode (str): The Ascii language code of the stream (ex: eng, tha). + requiredBandwidths (str): The required bandwidths to stream the file. + selected (bool): True if this stream is selected. + streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`, + 2= :class:`~plexapi.media.AudioStream`, 3= :class:`~plexapi.media.SubtitleStream`). + title (str): The title of the stream. + type (int): Alias for streamType. + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.bitrate = utils.cast(int, data.attrib.get('bitrate')) + self.codec = data.attrib.get('codec') + self.default = utils.cast(bool, data.attrib.get('default')) + self.displayTitle = data.attrib.get('displayTitle') + self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle') + self.key = data.attrib.get('key') + self.id = utils.cast(int, data.attrib.get('id')) + self.index = utils.cast(int, data.attrib.get('index', '-1')) + self.language = data.attrib.get('language') + self.languageCode = data.attrib.get('languageCode') + self.requiredBandwidths = data.attrib.get('requiredBandwidths') + self.selected = utils.cast(bool, data.attrib.get('selected', '0')) + self.streamType = utils.cast(int, data.attrib.get('streamType')) + self.title = data.attrib.get('title') + self.type = utils.cast(int, data.attrib.get('streamType')) + + +@utils.registerPlexObject +class VideoStream(MediaPartStream): + """ Represents a video stream within a :class:`~plexapi.media.MediaPart`. + + Attributes: + TAG (str): 'Stream' + STREAMTYPE (int): 1 + anamorphic (str): If the video is anamorphic. + bitDepth (int): The bit depth of the video stream (ex: 8). + cabac (int): The context-adaptive binary arithmetic coding. + chromaLocation (str): The chroma location of the video stream. + chromaSubsampling (str): The chroma subsampling of the video stream (ex: 4:2:0). + codecID (str): The codec ID (ex: XVID). + codedHeight (int): The coded height of the video stream in pixels. + codedWidth (int): The coded width of the video stream in pixels. + colorPrimaries (str): The color primaries of the video stream. + colorRange (str): The color range of the video stream. + colorSpace (str): The color space of the video stream (ex: bt2020). + colorTrc (str): The color trc of the video stream. + DOVIBLCompatID (int): Dolby Vision base layer compatibility ID. + DOVIBLPresent (bool): True if Dolby Vision base layer is present. + DOVIELPresent (bool): True if Dolby Vision enhancement layer is present. + DOVILevel (int): Dolby Vision level. + DOVIPresent (bool): True if Dolby Vision is present. + DOVIProfile (int): Dolby Vision profile. + DOVIRPUPresent (bool): True if Dolby Vision reference processing unit is present. + DOVIVersion (float): The Dolby Vision version. + duration (int): The duration of video stream in milliseconds. + frameRate (float): The frame rate of the video stream (ex: 23.976). + frameRateMode (str): The frame rate mode of the video stream. + hasScallingMatrix (bool): True if video stream has a scaling matrix. + height (int): The hight of the video stream in pixels (ex: 1080). + level (int): The codec encoding level of the video stream (ex: 41). + profile (str): The profile of the video stream (ex: asp). + pixelAspectRatio (str): The pixel aspect ratio of the video stream. + pixelFormat (str): The pixel format of the video stream. + refFrames (int): The number of reference frames of the video stream. + scanType (str): The scan type of the video stream (ex: progressive). + streamIdentifier(int): The stream identifier of the video stream. + width (int): The width of the video stream in pixels (ex: 1920). + """ + TAG = 'Stream' + STREAMTYPE = 1 + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(VideoStream, self)._loadData(data) + self.anamorphic = data.attrib.get('anamorphic') + self.bitDepth = utils.cast(int, data.attrib.get('bitDepth')) + self.cabac = utils.cast(int, data.attrib.get('cabac')) + self.chromaLocation = data.attrib.get('chromaLocation') + self.chromaSubsampling = data.attrib.get('chromaSubsampling') + self.codecID = data.attrib.get('codecID') + self.codedHeight = utils.cast(int, data.attrib.get('codedHeight')) + self.codedWidth = utils.cast(int, data.attrib.get('codedWidth')) + self.colorPrimaries = data.attrib.get('colorPrimaries') + self.colorRange = data.attrib.get('colorRange') + self.colorSpace = data.attrib.get('colorSpace') + self.colorTrc = data.attrib.get('colorTrc') + self.DOVIBLCompatID = utils.cast(int, data.attrib.get('DOVIBLCompatID')) + self.DOVIBLPresent = utils.cast(bool, data.attrib.get('DOVIBLPresent')) + self.DOVIELPresent = utils.cast(bool, data.attrib.get('DOVIELPresent')) + self.DOVILevel = utils.cast(int, data.attrib.get('DOVILevel')) + self.DOVIPresent = utils.cast(bool, data.attrib.get('DOVIPresent')) + self.DOVIProfile = utils.cast(int, data.attrib.get('DOVIProfile')) + self.DOVIRPUPresent = utils.cast(bool, data.attrib.get('DOVIRPUPresent')) + self.DOVIVersion = utils.cast(float, data.attrib.get('DOVIVersion')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.frameRate = utils.cast(float, data.attrib.get('frameRate')) + self.frameRateMode = data.attrib.get('frameRateMode') + self.hasScallingMatrix = utils.cast(bool, data.attrib.get('hasScallingMatrix')) + self.height = utils.cast(int, data.attrib.get('height')) + self.level = utils.cast(int, data.attrib.get('level')) + self.profile = data.attrib.get('profile') + self.pixelAspectRatio = data.attrib.get('pixelAspectRatio') + self.pixelFormat = data.attrib.get('pixelFormat') + self.refFrames = utils.cast(int, data.attrib.get('refFrames')) + self.scanType = data.attrib.get('scanType') + self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier')) + self.width = utils.cast(int, data.attrib.get('width')) + + +@utils.registerPlexObject +class AudioStream(MediaPartStream): + """ Represents a audio stream within a :class:`~plexapi.media.MediaPart`. + + Attributes: + TAG (str): 'Stream' + STREAMTYPE (int): 2 + audioChannelLayout (str): The audio channel layout of the audio stream (ex: 5.1(side)). + bitDepth (int): The bit depth of the audio stream (ex: 16). + bitrateMode (str): The bitrate mode of the audio stream (ex: cbr). + channels (int): The number of audio channels of the audio stream (ex: 6). + duration (int): The duration of audio stream in milliseconds. + profile (str): The profile of the audio stream. + samplingRate (int): The sampling rate of the audio stream (ex: xxx) + streamIdentifier (int): The stream identifier of the audio stream. + + <Track_only_attributes>: The following attributes are only available for tracks. + + * albumGain (float): The gain for the album. + * albumPeak (float): The peak for the album. + * albumRange (float): The range for the album. + * endRamp (str): The end ramp for the track. + * gain (float): The gain for the track. + * loudness (float): The loudness for the track. + * lra (float): The lra for the track. + * peak (float): The peak for the track. + * startRamp (str): The start ramp for the track. + """ + TAG = 'Stream' + STREAMTYPE = 2 + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(AudioStream, self)._loadData(data) + self.audioChannelLayout = data.attrib.get('audioChannelLayout') + self.bitDepth = utils.cast(int, data.attrib.get('bitDepth')) + self.bitrateMode = data.attrib.get('bitrateMode') + self.channels = utils.cast(int, data.attrib.get('channels')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.profile = data.attrib.get('profile') + self.samplingRate = utils.cast(int, data.attrib.get('samplingRate')) + self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier')) + + if self._isChildOf(etag='Track'): + self.albumGain = utils.cast(float, data.attrib.get('albumGain')) + self.albumPeak = utils.cast(float, data.attrib.get('albumPeak')) + self.albumRange = utils.cast(float, data.attrib.get('albumRange')) + self.endRamp = data.attrib.get('endRamp') + self.gain = utils.cast(float, data.attrib.get('gain')) + self.loudness = utils.cast(float, data.attrib.get('loudness')) + self.lra = utils.cast(float, data.attrib.get('lra')) + self.peak = utils.cast(float, data.attrib.get('peak')) + self.startRamp = data.attrib.get('startRamp') + + +@utils.registerPlexObject +class SubtitleStream(MediaPartStream): + """ Represents a audio stream within a :class:`~plexapi.media.MediaPart`. + + Attributes: + TAG (str): 'Stream' + STREAMTYPE (int): 3 + container (str): The container of the subtitle stream. + forced (bool): True if this is a forced subtitle. + format (str): The format of the subtitle stream (ex: srt). + headerCommpression (str): The header compression of the subtitle stream. + transient (str): Unknown. + """ + TAG = 'Stream' + STREAMTYPE = 3 + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(SubtitleStream, self)._loadData(data) + self.container = data.attrib.get('container') + self.forced = utils.cast(bool, data.attrib.get('forced', '0')) + self.format = data.attrib.get('format') + self.headerCompression = data.attrib.get('headerCompression') + self.transient = data.attrib.get('transient') + + +class LyricStream(MediaPartStream): + """ Represents a lyric stream within a :class:`~plexapi.media.MediaPart`. + + Attributes: + TAG (str): 'Stream' + STREAMTYPE (int): 4 + format (str): The format of the lyric stream (ex: lrc). + minLines (int): The minimum number of lines in the (timed) lyric stream. + provider (str): The provider of the lyric stream (ex: com.plexapp.agents.lyricfind). + timed (bool): True if the lyrics are timed to the track. + """ + TAG = 'Stream' + STREAMTYPE = 4 + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(LyricStream, self)._loadData(data) + self.format = data.attrib.get('format') + self.minLines = utils.cast(int, data.attrib.get('minLines')) + self.provider = data.attrib.get('provider') + self.timed = utils.cast(bool, data.attrib.get('timed', '0')) + + +@utils.registerPlexObject +class Session(PlexObject): + """ Represents a current session. + + Attributes: + TAG (str): 'Session' + id (str): The unique identifier for the session. + bandwidth (int): The Plex streaming brain reserved bandwidth for the session. + location (str): The location of the session (lan, wan, or cellular) + """ + TAG = 'Session' + + def _loadData(self, data): + self.id = data.attrib.get('id') + self.bandwidth = utils.cast(int, data.attrib.get('bandwidth')) + self.location = data.attrib.get('location') + + +@utils.registerPlexObject +class TranscodeSession(PlexObject): + """ Represents a current transcode session. + + Attributes: + TAG (str): 'TranscodeSession' + audioChannels (int): The number of audio channels of the transcoded media. + audioCodec (str): The audio codec of the transcoded media. + audioDecision (str): The transcode decision for the audio stream. + complete (bool): True if the transcode is complete. + container (str): The container of the transcoded media. + context (str): The context for the transcode sesson. + duration (int): The duration of the transcoded media in milliseconds. + height (int): The height of the transcoded media in pixels. + key (str): API URL (ex: /transcode/sessions/<id>). + maxOffsetAvailable (float): Unknown. + minOffsetAvailable (float): Unknown. + progress (float): The progress percentage of the transcode. + protocol (str): The protocol of the transcode. + remaining (int): Unknown. + size (int): The size of the transcoded media in bytes. + sourceAudioCodec (str): The audio codec of the source media. + sourceVideoCodec (str): The video codec of the source media. + speed (float): The speed of the transcode. + subtitleDecision (str): The transcode decision for the subtitle stream + throttled (bool): True if the transcode is throttled. + timestamp (int): The epoch timestamp when the transcode started. + transcodeHwDecoding (str): The hardware transcoding decoder engine. + transcodeHwDecodingTitle (str): The title of the hardware transcoding decoder engine. + transcodeHwEncoding (str): The hardware transcoding encoder engine. + transcodeHwEncodingTitle (str): The title of the hardware transcoding encoder engine. + transcodeHwFullPipeline (str): True if hardware decoding and encoding is being used for the transcode. + transcodeHwRequested (str): True if hardware transcoding was requested for the transcode. + videoCodec (str): The video codec of the transcoded media. + videoDecision (str): The transcode decision for the video stream. + width (str): The width of the transcoded media in pixels. + """ + TAG = 'TranscodeSession' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) + self.audioCodec = data.attrib.get('audioCodec') + self.audioDecision = data.attrib.get('audioDecision') + self.complete = utils.cast(bool, data.attrib.get('complete', '0')) + self.container = data.attrib.get('container') + self.context = data.attrib.get('context') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.height = utils.cast(int, data.attrib.get('height')) + self.key = data.attrib.get('key') + self.maxOffsetAvailable = utils.cast(float, data.attrib.get('maxOffsetAvailable')) + self.minOffsetAvailable = utils.cast(float, data.attrib.get('minOffsetAvailable')) + self.progress = utils.cast(float, data.attrib.get('progress')) + self.protocol = data.attrib.get('protocol') + self.remaining = utils.cast(int, data.attrib.get('remaining')) + self.size = utils.cast(int, data.attrib.get('size')) + self.sourceAudioCodec = data.attrib.get('sourceAudioCodec') + self.sourceVideoCodec = data.attrib.get('sourceVideoCodec') + self.speed = utils.cast(float, data.attrib.get('speed')) + self.subtitleDecision = data.attrib.get('subtitleDecision') + self.throttled = utils.cast(bool, data.attrib.get('throttled', '0')) + self.timestamp = utils.cast(float, data.attrib.get('timeStamp')) + self.transcodeHwDecoding = data.attrib.get('transcodeHwDecoding') + self.transcodeHwDecodingTitle = data.attrib.get('transcodeHwDecodingTitle') + self.transcodeHwEncoding = data.attrib.get('transcodeHwEncoding') + self.transcodeHwEncodingTitle = data.attrib.get('transcodeHwEncodingTitle') + self.transcodeHwFullPipeline = utils.cast(bool, data.attrib.get('transcodeHwFullPipeline', '0')) + self.transcodeHwRequested = utils.cast(bool, data.attrib.get('transcodeHwRequested', '0')) + self.videoCodec = data.attrib.get('videoCodec') + self.videoDecision = data.attrib.get('videoDecision') + self.width = utils.cast(int, data.attrib.get('width')) + + +@utils.registerPlexObject +class TranscodeJob(PlexObject): + """ Represents an Optimizing job. + TrancodeJobs are the process for optimizing conversions. + Active or paused optimization items. Usually one item as a time.""" + TAG = 'TranscodeJob' + + def _loadData(self, data): + self._data = data + self.generatorID = data.attrib.get('generatorID') + self.key = data.attrib.get('key') + self.progress = data.attrib.get('progress') + self.ratingKey = data.attrib.get('ratingKey') + self.size = data.attrib.get('size') + self.targetTagID = data.attrib.get('targetTagID') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + + +@utils.registerPlexObject +class Optimized(PlexObject): + """ Represents a Optimized item. + Optimized items are optimized and queued conversions items.""" + TAG = 'Item' + + def _loadData(self, data): + self._data = data + self.id = data.attrib.get('id') + self.composite = data.attrib.get('composite') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.target = data.attrib.get('target') + self.targetTagID = data.attrib.get('targetTagID') + + def items(self): + """ Returns a list of all :class:`~plexapi.media.Video` objects + in this optimized item. + """ + key = '%s/%s/items' % (self._initpath, self.id) + return self.fetchItems(key) + + def remove(self): + """ Remove an Optimized item""" + key = '%s/%s' % (self._initpath, self.id) + self._server.query(key, method=self._server._session.delete) + + def rename(self, title): + """ Rename an Optimized item""" + key = '%s/%s?Item[title]=%s' % (self._initpath, self.id, title) + self._server.query(key, method=self._server._session.put) + + def reprocess(self, ratingKey): + """ Reprocess a removed Conversion item that is still a listed Optimize item""" + key = '%s/%s/%s/enable' % (self._initpath, self.id, ratingKey) + self._server.query(key, method=self._server._session.put) + + +@utils.registerPlexObject +class Conversion(PlexObject): + """ Represents a Conversion item. + Conversions are items queued for optimization or being actively optimized.""" + TAG = 'Video' + + def _loadData(self, data): + self._data = data + self.addedAt = data.attrib.get('addedAt') + self.art = data.attrib.get('art') + self.chapterSource = data.attrib.get('chapterSource') + self.contentRating = data.attrib.get('contentRating') + self.duration = data.attrib.get('duration') + self.generatorID = data.attrib.get('generatorID') + self.generatorType = data.attrib.get('generatorType') + self.guid = data.attrib.get('guid') + self.key = data.attrib.get('key') + self.lastViewedAt = data.attrib.get('lastViewedAt') + self.librarySectionID = data.attrib.get('librarySectionID') + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.originallyAvailableAt = data.attrib.get('originallyAvailableAt') + self.playQueueItemID = data.attrib.get('playQueueItemID') + self.playlistID = data.attrib.get('playlistID') + self.primaryExtraKey = data.attrib.get('primaryExtraKey') + self.rating = data.attrib.get('rating') + self.ratingKey = data.attrib.get('ratingKey') + self.studio = data.attrib.get('studio') + self.summary = data.attrib.get('summary') + self.tagline = data.attrib.get('tagline') + self.target = data.attrib.get('target') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.updatedAt = data.attrib.get('updatedAt') + self.userID = data.attrib.get('userID') + self.username = data.attrib.get('username') + self.viewOffset = data.attrib.get('viewOffset') + self.year = data.attrib.get('year') + + def remove(self): + """ Remove Conversion from queue """ + key = '/playlists/%s/items/%s/%s/disable' % (self.playlistID, self.generatorID, self.ratingKey) + self._server.query(key, method=self._server._session.put) + + def move(self, after): + """ Move Conversion items position in queue + after (int): Place item after specified playQueueItemID. '-1' is the active conversion. + + Example: + Move 5th conversion Item to active conversion + conversions[4].move('-1') + + Move 4th conversion Item to 3rd in conversion queue + conversions[3].move(conversions[1].playQueueItemID) + """ + + key = '%s/items/%s/move?after=%s' % (self._initpath, self.playQueueItemID, after) + self._server.query(key, method=self._server._session.put) + + +class MediaTag(PlexObject): + """ Base class for media tags used for filtering and searching your library + items or navigating the metadata of media items in your library. Tags are + the construct used for things such as Country, Director, Genre, etc. + + Attributes: + filter (str): The library filter for the tag. + id (id): Tag ID (This seems meaningless except to use it as a unique id). + key (str): API URL (/library/section/<librarySectionID>/all?<filter>). + role (str): The name of the character role for :class:`~plexapi.media.Role` only. + tag (str): Name of the tag. This will be Animation, SciFi etc for Genres. The name of + person for Directors and Roles (ex: Animation, Stephen Graham, etc). + thumb (str): URL to thumbnail image for :class:`~plexapi.media.Role` only. + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.filter = data.attrib.get('filter') + self.id = utils.cast(int, data.attrib.get('id')) + self.key = data.attrib.get('key') + self.role = data.attrib.get('role') + self.tag = data.attrib.get('tag') + self.thumb = data.attrib.get('thumb') + + parent = self._parent() + self._librarySectionID = utils.cast(int, parent._data.attrib.get('librarySectionID')) + self._librarySectionKey = parent._data.attrib.get('librarySectionKey') + self._librarySectionTitle = parent._data.attrib.get('librarySectionTitle') + self._parentType = parent.TYPE + + if self._librarySectionKey and self.filter: + self.key = '%s/all?%s&type=%s' % ( + self._librarySectionKey, self.filter, utils.searchType(self._parentType)) + + def items(self): + """ Return the list of items within this tag. """ + if not self.key: + raise BadRequest('Key is not defined for this tag: %s. ' + 'Reload the parent object.' % self.tag) + return self.fetchItems(self.key) + + +@utils.registerPlexObject +class Collection(MediaTag): + """ Represents a single Collection media tag. + + Attributes: + TAG (str): 'Collection' + FILTER (str): 'collection' + """ + TAG = 'Collection' + FILTER = 'collection' + + def collection(self): + """ Return the :class:`~plexapi.collection.Collection` object for this collection tag. + """ + key = '%s/collections' % self._librarySectionKey + return self.fetchItem(key, etag='Directory', index=self.id) + + +@utils.registerPlexObject +class Country(MediaTag): + """ Represents a single Country media tag. + + Attributes: + TAG (str): 'Country' + FILTER (str): 'country' + """ + TAG = 'Country' + FILTER = 'country' + + +@utils.registerPlexObject +class Director(MediaTag): + """ Represents a single Director media tag. + + Attributes: + TAG (str): 'Director' + FILTER (str): 'director' + """ + TAG = 'Director' + FILTER = 'director' + + +@utils.registerPlexObject +class Format(MediaTag): + """ Represents a single Format media tag. + + Attributes: + TAG (str): 'Format' + FILTER (str): 'format' + """ + TAG = 'Format' + FILTER = 'format' + + +@utils.registerPlexObject +class Genre(MediaTag): + """ Represents a single Genre media tag. + + Attributes: + TAG (str): 'Genre' + FILTER (str): 'genre' + """ + TAG = 'Genre' + FILTER = 'genre' + + +@utils.registerPlexObject +class Label(MediaTag): + """ Represents a single Label media tag. + + Attributes: + TAG (str): 'Label' + FILTER (str): 'label' + """ + TAG = 'Label' + FILTER = 'label' + + +@utils.registerPlexObject +class Mood(MediaTag): + """ Represents a single Mood media tag. + + Attributes: + TAG (str): 'Mood' + FILTER (str): 'mood' + """ + TAG = 'Mood' + FILTER = 'mood' + + +@utils.registerPlexObject +class Producer(MediaTag): + """ Represents a single Producer media tag. + + Attributes: + TAG (str): 'Producer' + FILTER (str): 'producer' + """ + TAG = 'Producer' + FILTER = 'producer' + + +@utils.registerPlexObject +class Role(MediaTag): + """ Represents a single Role (actor/actress) media tag. + + Attributes: + TAG (str): 'Role' + FILTER (str): 'role' + """ + TAG = 'Role' + FILTER = 'role' + + +@utils.registerPlexObject +class Similar(MediaTag): + """ Represents a single Similar media tag. + + Attributes: + TAG (str): 'Similar' + FILTER (str): 'similar' + """ + TAG = 'Similar' + FILTER = 'similar' + + +@utils.registerPlexObject +class Style(MediaTag): + """ Represents a single Style media tag. + + Attributes: + TAG (str): 'Style' + FILTER (str): 'style' + """ + TAG = 'Style' + FILTER = 'style' + + +@utils.registerPlexObject +class Subformat(MediaTag): + """ Represents a single Subformat media tag. + + Attributes: + TAG (str): 'Subformat' + FILTER (str): 'subformat' + """ + TAG = 'Subformat' + FILTER = 'subformat' + + +@utils.registerPlexObject +class Tag(MediaTag): + """ Represents a single Tag media tag. + + Attributes: + TAG (str): 'Tag' + FILTER (str): 'tag' + """ + TAG = 'Tag' + FILTER = 'tag' + + +@utils.registerPlexObject +class Writer(MediaTag): + """ Represents a single Writer media tag. + + Attributes: + TAG (str): 'Writer' + FILTER (str): 'writer' + """ + TAG = 'Writer' + FILTER = 'writer' + + +class GuidTag(PlexObject): + """ Base class for guid tags used only for Guids, as they contain only a string identifier + + Attributes: + id (id): The guid for external metadata sources (e.g. IMDB, TMDB, TVDB). + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.id = data.attrib.get('id') + + +@utils.registerPlexObject +class Guid(GuidTag): + """ Represents a single Guid media tag. + + Attributes: + TAG (str): 'Guid' + """ + TAG = 'Guid' + + +@utils.registerPlexObject +class Review(PlexObject): + """ Represents a single Review for a Movie. + + Attributes: + TAG (str): 'Review' + filter (str): filter for reviews? + id (int): The ID of the review. + image (str): The image uri for the review. + link (str): The url to the online review. + source (str): The source of the review. + tag (str): The name of the reviewer. + text (str): The text of the review. + """ + TAG = 'Review' + + def _loadData(self, data): + self._data = data + self.filter = data.attrib.get('filter') + self.id = utils.cast(int, data.attrib.get('id', 0)) + self.image = data.attrib.get('image') + self.link = data.attrib.get('link') + self.source = data.attrib.get('source') + self.tag = data.attrib.get('tag') + self.text = data.attrib.get('text') + + +class BaseImage(PlexObject): + """ Base class for all Art, Banner, and Poster objects. + + Attributes: + TAG (str): 'Photo' + key (str): API URL (/library/metadata/<ratingkey>). + provider (str): The source of the poster or art. + ratingKey (str): Unique key identifying the poster or art. + selected (bool): True if the poster or art is currently selected. + thumb (str): The URL to retrieve the poster or art thumbnail. + """ + TAG = 'Photo' + + def _loadData(self, data): + self._data = data + self.key = data.attrib.get('key') + self.provider = data.attrib.get('provider') + self.ratingKey = data.attrib.get('ratingKey') + self.selected = utils.cast(bool, data.attrib.get('selected')) + self.thumb = data.attrib.get('thumb') + + def select(self): + key = self._initpath[:-1] + data = '%s?url=%s' % (key, quote_plus(self.ratingKey)) + try: + self._server.query(data, method=self._server._session.put) + except xml.etree.ElementTree.ParseError: + pass + + +class Art(BaseImage): + """ Represents a single Art object. """ + + +class Banner(BaseImage): + """ Represents a single Banner object. """ + + +class Poster(BaseImage): + """ Represents a single Poster object. """ + + +@utils.registerPlexObject +class Chapter(PlexObject): + """ Represents a single Writer media tag. + + Attributes: + TAG (str): 'Chapter' + """ + TAG = 'Chapter' + + def _loadData(self, data): + self._data = data + self.id = utils.cast(int, data.attrib.get('id', 0)) + self.filter = data.attrib.get('filter') # I couldn't filter on it anyways + self.tag = data.attrib.get('tag') + self.title = self.tag + self.index = utils.cast(int, data.attrib.get('index')) + self.start = utils.cast(int, data.attrib.get('startTimeOffset')) + self.end = utils.cast(int, data.attrib.get('endTimeOffset')) + + +@utils.registerPlexObject +class Marker(PlexObject): + """ Represents a single Marker media tag. + + Attributes: + TAG (str): 'Marker' + """ + TAG = 'Marker' + + def __repr__(self): + name = self._clean(self.firstAttr('type')) + start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start'))) + end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end'))) + offsets = '%s-%s' % (start, end) + return '<%s>' % ':'.join([self.__class__.__name__, name, offsets]) + + def _loadData(self, data): + self._data = data + self.id = utils.cast(int, data.attrib.get('id')) + self.type = data.attrib.get('type') + self.start = utils.cast(int, data.attrib.get('startTimeOffset')) + self.end = utils.cast(int, data.attrib.get('endTimeOffset')) + + +@utils.registerPlexObject +class Field(PlexObject): + """ Represents a single Field. + + Attributes: + TAG (str): 'Field' + """ + TAG = 'Field' + + def _loadData(self, data): + self._data = data + self.name = data.attrib.get('name') + self.locked = utils.cast(bool, data.attrib.get('locked')) + + +@utils.registerPlexObject +class SearchResult(PlexObject): + """ Represents a single SearchResult. + + Attributes: + TAG (str): 'SearchResult' + """ + TAG = 'SearchResult' + + def __repr__(self): + name = self._clean(self.firstAttr('name')) + score = self._clean(self.firstAttr('score')) + return '<%s>' % ':'.join([p for p in [self.__class__.__name__, name, score] if p]) + + def _loadData(self, data): + self._data = data + self.guid = data.attrib.get('guid') + self.lifespanEnded = data.attrib.get('lifespanEnded') + self.name = data.attrib.get('name') + self.score = utils.cast(int, data.attrib.get('score')) + self.year = data.attrib.get('year') + + +@utils.registerPlexObject +class Agent(PlexObject): + """ Represents a single Agent. + + Attributes: + TAG (str): 'Agent' + """ + TAG = 'Agent' + + def __repr__(self): + uid = self._clean(self.firstAttr('shortIdentifier')) + return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p]) + + def _loadData(self, data): + self._data = data + self.hasAttribution = data.attrib.get('hasAttribution') + self.hasPrefs = data.attrib.get('hasPrefs') + self.identifier = data.attrib.get('identifier') + self.primary = data.attrib.get('primary') + self.shortIdentifier = self.identifier.rsplit('.', 1)[1] + if 'mediaType' in self._initpath: + self.name = data.attrib.get('name') + self.languageCode = [] + for code in data: + self.languageCode += [code.attrib.get('code')] + else: + self.mediaTypes = [AgentMediaType(server=self._server, data=d) for d in data] + + def _settings(self): + key = '/:/plugins/%s/prefs' % self.identifier + data = self._server.query(key) + return self.findItems(data, cls=settings.Setting) + + +class AgentMediaType(Agent): + + def __repr__(self): + uid = self._clean(self.firstAttr('name')) + return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p]) + + def _loadData(self, data): + self.mediaType = utils.cast(int, data.attrib.get('mediaType')) + self.name = data.attrib.get('name') + self.languageCode = [] + for code in data: + self.languageCode += [code.attrib.get('code')] diff --git a/service.plexskipintro/plexapi/mixins.py b/service.plexskipintro/plexapi/mixins.py new file mode 100644 index 0000000000..c9365a4732 --- /dev/null +++ b/service.plexskipintro/plexapi/mixins.py @@ -0,0 +1,645 @@ +# -*- coding: utf-8 -*- +from urllib.parse import parse_qsl, quote_plus, unquote, urlencode, urlsplit + +from plexapi import media, settings, utils +from plexapi.exceptions import BadRequest, NotFound + + +class AdvancedSettingsMixin(object): + """ Mixin for Plex objects that can have advanced settings. """ + + def preferences(self): + """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ + data = self._server.query(self._details_key) + return self.findItems(data, settings.Preferences, rtag='Preferences') + + def preference(self, pref): + """ Returns a :class:`~plexapi.settings.Preferences` object for the specified pref. + + Parameters: + pref (str): The id of the preference to return. + """ + prefs = self.preferences() + try: + return next(p for p in prefs if p.id == pref) + except StopIteration: + availablePrefs = [p.id for p in prefs] + raise NotFound('Unknown preference "%s" for %s. ' + 'Available preferences: %s' + % (pref, self.TYPE, availablePrefs)) from None + + def editAdvanced(self, **kwargs): + """ Edit a Plex object's advanced settings. """ + data = {} + key = '%s/prefs?' % self.key + preferences = {pref.id: pref for pref in self.preferences() if pref.enumValues} + for settingID, value in list(kwargs.items()): + try: + pref = preferences[settingID] + except KeyError: + raise NotFound('%s not found in %s' % (value, list(preferences.keys()))) + + enumValues = pref.enumValues + if enumValues.get(value, enumValues.get(str(value))): + data[settingID] = value + else: + raise NotFound('%s not found in %s' % (value, list(enumValues))) + url = key + urlencode(data) + self._server.query(url, method=self._server._session.put) + + def defaultAdvanced(self): + """ Edit all of a Plex object's advanced settings to default. """ + data = {} + key = '%s/prefs?' % self.key + for preference in self.preferences(): + data[preference.id] = preference.default + url = key + urlencode(data) + self._server.query(url, method=self._server._session.put) + + +class ArtUrlMixin(object): + """ Mixin for Plex objects that can have a background artwork url. """ + + @property + def artUrl(self): + """ Return the art url for the Plex object. """ + art = self.firstAttr('art', 'grandparentArt') + return self._server.url(art, includeToken=True) if art else None + + +class ArtMixin(ArtUrlMixin): + """ Mixin for Plex objects that can have background artwork. """ + + def arts(self): + """ Returns list of available :class:`~plexapi.media.Art` objects. """ + return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey, cls=media.Art) + + def uploadArt(self, url=None, filepath=None): + """ Upload a background artwork from a url or filepath. + + Parameters: + url (str): The full URL to the image to upload. + filepath (str): The full file path the the image to upload. + """ + if url: + key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/arts?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setArt(self, art): + """ Set the background artwork for a Plex object. + + Parameters: + art (:class:`~plexapi.media.Art`): The art object to select. + """ + art.select() + + def lockArt(self): + """ Lock the background artwork for a Plex object. """ + self._edit(**{'art.locked': 1}) + + def unlockArt(self): + """ Unlock the background artwork for a Plex object. """ + self._edit(**{'art.locked': 0}) + + +class BannerUrlMixin(object): + """ Mixin for Plex objects that can have a banner url. """ + + @property + def bannerUrl(self): + """ Return the banner url for the Plex object. """ + banner = self.firstAttr('banner') + return self._server.url(banner, includeToken=True) if banner else None + + +class BannerMixin(BannerUrlMixin): + """ Mixin for Plex objects that can have banners. """ + + def banners(self): + """ Returns list of available :class:`~plexapi.media.Banner` objects. """ + return self.fetchItems('/library/metadata/%s/banners' % self.ratingKey, cls=media.Banner) + + def uploadBanner(self, url=None, filepath=None): + """ Upload a banner from a url or filepath. + + Parameters: + url (str): The full URL to the image to upload. + filepath (str): The full file path the the image to upload. + """ + if url: + key = '/library/metadata/%s/banners?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/banners?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setBanner(self, banner): + """ Set the banner for a Plex object. + + Parameters: + banner (:class:`~plexapi.media.Banner`): The banner object to select. + """ + banner.select() + + def lockBanner(self): + """ Lock the banner for a Plex object. """ + self._edit(**{'banner.locked': 1}) + + def unlockBanner(self): + """ Unlock the banner for a Plex object. """ + self._edit(**{'banner.locked': 0}) + + +class PosterUrlMixin(object): + """ Mixin for Plex objects that can have a poster url. """ + + @property + def thumbUrl(self): + """ Return the thumb url for the Plex object. """ + thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') + return self._server.url(thumb, includeToken=True) if thumb else None + + @property + def posterUrl(self): + """ Alias to self.thumbUrl. """ + return self.thumbUrl + + +class PosterMixin(PosterUrlMixin): + """ Mixin for Plex objects that can have posters. """ + + def posters(self): + """ Returns list of available :class:`~plexapi.media.Poster` objects. """ + return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey, cls=media.Poster) + + def uploadPoster(self, url=None, filepath=None): + """ Upload a poster from a url or filepath. + + Parameters: + url (str): The full URL to the image to upload. + filepath (str): The full file path the the image to upload. + """ + if url: + key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/posters?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setPoster(self, poster): + """ Set the poster for a Plex object. + + Parameters: + poster (:class:`~plexapi.media.Poster`): The poster object to select. + """ + poster.select() + + def lockPoster(self): + """ Lock the poster for a Plex object. """ + self._edit(**{'thumb.locked': 1}) + + def unlockPoster(self): + """ Unlock the poster for a Plex object. """ + self._edit(**{'thumb.locked': 0}) + + +class RatingMixin(object): + """ Mixin for Plex objects that can have user star ratings. """ + + def rate(self, rating=None): + """ Rate the Plex object. Note: Plex ratings are displayed out of 5 stars (e.g. rating 7.0 = 3.5 stars). + + Parameters: + rating (float, optional): Rating from 0 to 10. Exclude to reset the rating. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If the rating is invalid. + """ + if rating is None: + rating = -1 + elif not isinstance(rating, (int, float)) or rating < 0 or rating > 10: + raise BadRequest('Rating must be between 0 to 10.') + key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rating) + self._server.query(key, method=self._server._session.put) + + +class SplitMergeMixin(object): + """ Mixin for Plex objects that can be split and merged. """ + + def split(self): + """ Split duplicated Plex object into separate objects. """ + key = '/library/metadata/%s/split' % self.ratingKey + return self._server.query(key, method=self._server._session.put) + + def merge(self, ratingKeys): + """ Merge other Plex objects into the current object. + + Parameters: + ratingKeys (list): A list of rating keys to merge. + """ + if not isinstance(ratingKeys, list): + ratingKeys = str(ratingKeys).split(',') + + key = '%s/merge?ids=%s' % (self.key, ','.join([str(r) for r in ratingKeys])) + return self._server.query(key, method=self._server._session.put) + + +class UnmatchMatchMixin(object): + """ Mixin for Plex objects that can be unmatched and matched. """ + + def unmatch(self): + """ Unmatches metadata match from object. """ + key = '/library/metadata/%s/unmatch' % self.ratingKey + self._server.query(key, method=self._server._session.put) + + def matches(self, agent=None, title=None, year=None, language=None): + """ Return list of (:class:`~plexapi.media.SearchResult`) metadata matches. + + Parameters: + agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) + title (str): Title of item to search for + year (str): Year of item to search in + language (str) : Language of item to search in + + Examples: + 1. video.matches() + 2. video.matches(title="something", year=2020) + 3. video.matches(title="something") + 4. video.matches(year=2020) + 5. video.matches(title="something", year="") + 6. video.matches(title="", year=2020) + 7. video.matches(title="", year="") + + 1. The default behaviour in Plex Web = no params in plexapi + 2. Both title and year specified by user + 3. Year automatically filled in + 4. Title automatically filled in + 5. Explicitly searches for title with blank year + 6. Explicitly searches for blank title with year + 7. I don't know what the user is thinking... return the same result as 1 + + For 2 to 7, the agent and language is automatically filled in + """ + key = '/library/metadata/%s/matches' % self.ratingKey + params = {'manual': 1} + + if agent and not any([title, year, language]): + params['language'] = self.section().language + params['agent'] = utils.getAgentIdentifier(self.section(), agent) + else: + if any(x is not None for x in [agent, title, year, language]): + if title is None: + params['title'] = self.title + else: + params['title'] = title + + if year is None: + params['year'] = self.year + else: + params['year'] = year + + params['language'] = language or self.section().language + + if agent is None: + params['agent'] = self.section().agent + else: + params['agent'] = utils.getAgentIdentifier(self.section(), agent) + + key = key + '?' + urlencode(params) + data = self._server.query(key, method=self._server._session.get) + return self.findItems(data, initpath=key) + + def fixMatch(self, searchResult=None, auto=False, agent=None): + """ Use match result to update show metadata. + + Parameters: + auto (bool): True uses first match from matches + False allows user to provide the match + searchResult (:class:`~plexapi.media.SearchResult`): Search result from + ~plexapi.base.matches() + agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) + """ + key = '/library/metadata/%s/match' % self.ratingKey + if auto: + autoMatch = self.matches(agent=agent) + if autoMatch: + searchResult = autoMatch[0] + else: + raise NotFound('No matches found using this agent: (%s:%s)' % (agent, autoMatch)) + elif not searchResult: + raise NotFound('fixMatch() requires either auto=True or ' + 'searchResult=:class:`~plexapi.media.SearchResult`.') + + params = {'guid': searchResult.guid, + 'name': searchResult.name} + + data = key + '?' + urlencode(params) + self._server.query(data, method=self._server._session.put) + + +class CollectionMixin(object): + """ Mixin for Plex objects that can have collections. """ + + def addCollection(self, collections, locked=True): + """ Add a collection tag(s). + + Parameters: + collections (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('collection', collections, locked=locked) + + def removeCollection(self, collections, locked=True): + """ Remove a collection tag(s). + + Parameters: + collections (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('collection', collections, locked=locked, remove=True) + + +class CountryMixin(object): + """ Mixin for Plex objects that can have countries. """ + + def addCountry(self, countries, locked=True): + """ Add a country tag(s). + + Parameters: + countries (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('country', countries, locked=locked) + + def removeCountry(self, countries, locked=True): + """ Remove a country tag(s). + + Parameters: + countries (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('country', countries, locked=locked, remove=True) + + +class DirectorMixin(object): + """ Mixin for Plex objects that can have directors. """ + + def addDirector(self, directors, locked=True): + """ Add a director tag(s). + + Parameters: + directors (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('director', directors, locked=locked) + + def removeDirector(self, directors, locked=True): + """ Remove a director tag(s). + + Parameters: + directors (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('director', directors, locked=locked, remove=True) + + +class GenreMixin(object): + """ Mixin for Plex objects that can have genres. """ + + def addGenre(self, genres, locked=True): + """ Add a genre tag(s). + + Parameters: + genres (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('genre', genres, locked=locked) + + def removeGenre(self, genres, locked=True): + """ Remove a genre tag(s). + + Parameters: + genres (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('genre', genres, locked=locked, remove=True) + + +class LabelMixin(object): + """ Mixin for Plex objects that can have labels. """ + + def addLabel(self, labels, locked=True): + """ Add a label tag(s). + + Parameters: + labels (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('label', labels, locked=locked) + + def removeLabel(self, labels, locked=True): + """ Remove a label tag(s). + + Parameters: + labels (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('label', labels, locked=locked, remove=True) + + +class MoodMixin(object): + """ Mixin for Plex objects that can have moods. """ + + def addMood(self, moods, locked=True): + """ Add a mood tag(s). + + Parameters: + moods (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('mood', moods, locked=locked) + + def removeMood(self, moods, locked=True): + """ Remove a mood tag(s). + + Parameters: + moods (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('mood', moods, locked=locked, remove=True) + + +class ProducerMixin(object): + """ Mixin for Plex objects that can have producers. """ + + def addProducer(self, producers, locked=True): + """ Add a producer tag(s). + + Parameters: + producers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('producer', producers, locked=locked) + + def removeProducer(self, producers, locked=True): + """ Remove a producer tag(s). + + Parameters: + producers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('producer', producers, locked=locked, remove=True) + + +class SimilarArtistMixin(object): + """ Mixin for Plex objects that can have similar artists. """ + + def addSimilarArtist(self, artists, locked=True): + """ Add a similar artist tag(s). + + Parameters: + artists (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('similar', artists, locked=locked) + + def removeSimilarArtist(self, artists, locked=True): + """ Remove a similar artist tag(s). + + Parameters: + artists (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('similar', artists, locked=locked, remove=True) + + +class StyleMixin(object): + """ Mixin for Plex objects that can have styles. """ + + def addStyle(self, styles, locked=True): + """ Add a style tag(s). + + Parameters: + styles (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('style', styles, locked=locked) + + def removeStyle(self, styles, locked=True): + """ Remove a style tag(s). + + Parameters: + styles (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('style', styles, locked=locked, remove=True) + + +class TagMixin(object): + """ Mixin for Plex objects that can have tags. """ + + def addTag(self, tags, locked=True): + """ Add a tag(s). + + Parameters: + tags (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('tag', tags, locked=locked) + + def removeTag(self, tags, locked=True): + """ Remove a tag(s). + + Parameters: + tags (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('tag', tags, locked=locked, remove=True) + + +class WriterMixin(object): + """ Mixin for Plex objects that can have writers. """ + + def addWriter(self, writers, locked=True): + """ Add a writer tag(s). + + Parameters: + writers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('writer', writers, locked=locked) + + def removeWriter(self, writers, locked=True): + """ Remove a writer tag(s). + + Parameters: + writers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('writer', writers, locked=locked, remove=True) + + +class SmartFilterMixin(object): + """ Mixing for Plex objects that can have smart filters. """ + + def _parseFilters(self, content): + """ Parse the content string and returns the filter dict. """ + content = urlsplit(unquote(content)) + filters = {} + filterOp = 'and' + filterGroups = [[]] + + for key, value in parse_qsl(content.query): + # Move = sign to key when operator is == + if value.startswith('='): + key += '=' + value = value[1:] + + if key == 'includeGuids': + filters['includeGuids'] = int(value) + elif key == 'type': + filters['libtype'] = utils.reverseSearchType(value) + elif key == 'sort': + filters['sort'] = value.split(',') + elif key == 'limit': + filters['limit'] = int(value) + elif key == 'push': + filterGroups[-1].append([]) + filterGroups.append(filterGroups[-1][-1]) + elif key == 'and': + filterOp = 'and' + elif key == 'or': + filterOp = 'or' + elif key == 'pop': + filterGroups[-1].insert(0, filterOp) + filterGroups.pop() + else: + filterGroups[-1].append({key: value}) + + if filterGroups: + filters['filters'] = self._formatFilterGroups(filterGroups.pop()) + return filters + + def _formatFilterGroups(self, groups): + """ Formats the filter groups into the advanced search rules. """ + if len(groups) == 1 and isinstance(groups[0], list): + groups = groups.pop() + + filterOp = 'and' + rules = [] + + for g in groups: + if isinstance(g, list): + rules.append(self._formatFilterGroups(g)) + elif isinstance(g, dict): + rules.append(g) + elif g in {'and', 'or'}: + filterOp = g + + return {filterOp: rules} diff --git a/service.plexskipintro/plexapi/myplex.py b/service.plexskipintro/plexapi/myplex.py new file mode 100644 index 0000000000..ddadce5ef5 --- /dev/null +++ b/service.plexskipintro/plexapi/myplex.py @@ -0,0 +1,1508 @@ +# -*- coding: utf-8 -*- +import copy +import threading +import time +from xml.etree import ElementTree + +import requests +from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, + X_PLEX_IDENTIFIER, log, logfilter, utils) +from plexapi.base import PlexObject +from plexapi.client import PlexClient +from plexapi.exceptions import BadRequest, NotFound, Unauthorized +from plexapi.library import LibrarySection +from plexapi.server import PlexServer +from plexapi.sonos import PlexSonosClient +from plexapi.sync import SyncItem, SyncList +from requests.status_codes import _codes as codes + + +class MyPlexAccount(PlexObject): + """ MyPlex account and profile information. This object represents the data found Account on + the myplex.tv servers at the url https://plex.tv/users/account. You may create this object + directly by passing in your username & password (or token). There is also a convenience + method provided at :class:`~plexapi.server.PlexServer.myPlexAccount()` which will create + and return this object. + + Parameters: + username (str): Your MyPlex username. + password (str): Your MyPlex password. + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from PMS + timeout (int): timeout in seconds on initial connect to myplex (default config.TIMEOUT). + + Attributes: + SIGNIN (str): 'https://plex.tv/users/sign_in.xml' + key (str): 'https://plex.tv/users/account' + authenticationToken (str): Unknown. + certificateVersion (str): Unknown. + cloudSyncDevice (str): Unknown. + email (str): Your current Plex email address. + entitlements (List<str>): List of devices your allowed to use with this account. + guest (bool): Unknown. + home (bool): Unknown. + homeSize (int): Unknown. + id (int): Your Plex account ID. + locale (str): Your Plex locale + mailing_list_status (str): Your current mailing list status. + maxHomeSize (int): Unknown. + queueEmail (str): Email address to add items to your `Watch Later` queue. + queueUid (str): Unknown. + restricted (bool): Unknown. + roles: (List<str>) Lit of account roles. Plexpass membership listed here. + scrobbleTypes (str): Description + secure (bool): Description + subscriptionActive (bool): True if your subsctiption is active. + subscriptionFeatures: (List<str>) List of features allowed on your subscription. + subscriptionPlan (str): Name of subscription plan. + subscriptionStatus (str): String representation of `subscriptionActive`. + thumb (str): URL of your account thumbnail. + title (str): Unknown. - Looks like an alias for `username`. + username (str): Your account username. + uuid (str): Unknown. + _token (str): Token used to access this client. + _session (obj): Requests session object used to access this client. + """ + FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data + HOMEUSERCREATE = 'https://plex.tv/api/home/users?title={title}' # post with data + EXISTINGUSER = 'https://plex.tv/api/home/users?invitedEmail={username}' # post with data + FRIENDSERVERS = 'https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}' # put with data + PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get + FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete + REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete + SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth + WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data + OPTOUTS = 'https://plex.tv/api/v2/user/%(userUUID)s/settings/opt_outs' # get + LINK = 'https://plex.tv/api/v2/pins/link' # put + # Hub sections + VOD = 'https://vod.provider.plex.tv/' # get + WEBSHOWS = 'https://webshows.provider.plex.tv/' # get + NEWS = 'https://news.provider.plex.tv/' # get + PODCASTS = 'https://podcasts.provider.plex.tv/' # get + MUSIC = 'https://music.provider.plex.tv/' # get + # Key may someday switch to the following url. For now the current value works. + # https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId} + key = 'https://plex.tv/users/account' + + def __init__(self, username=None, password=None, token=None, session=None, timeout=None): + self._token = token or CONFIG.get('auth.server_token') + self._session = session or requests.Session() + self._sonos_cache = [] + self._sonos_cache_timestamp = 0 + data, initpath = self._signin(username, password, timeout) + super(MyPlexAccount, self).__init__(self, data, initpath) + + def _signin(self, username, password, timeout): + if self._token: + return self.query(self.key), self.key + username = username or CONFIG.get('auth.myplex_username') + password = password or CONFIG.get('auth.myplex_password') + data = self.query(self.SIGNIN, method=self._session.post, auth=(username, password), timeout=timeout) + return data, self.SIGNIN + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self._token = logfilter.add_secret(data.attrib.get('authenticationToken')) + self._webhooks = [] + self.authenticationToken = self._token + self.certificateVersion = data.attrib.get('certificateVersion') + self.cloudSyncDevice = data.attrib.get('cloudSyncDevice') + self.email = data.attrib.get('email') + self.guest = utils.cast(bool, data.attrib.get('guest')) + self.home = utils.cast(bool, data.attrib.get('home')) + self.homeSize = utils.cast(int, data.attrib.get('homeSize')) + self.id = utils.cast(int, data.attrib.get('id')) + self.locale = data.attrib.get('locale') + self.mailing_list_status = data.attrib.get('mailing_list_status') + self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize')) + self.queueEmail = data.attrib.get('queueEmail') + self.queueUid = data.attrib.get('queueUid') + self.restricted = utils.cast(bool, data.attrib.get('restricted')) + self.scrobbleTypes = data.attrib.get('scrobbleTypes') + self.secure = utils.cast(bool, data.attrib.get('secure')) + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.username = data.attrib.get('username') + self.uuid = data.attrib.get('uuid') + + subscription = data.find('subscription') + self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active')) + self.subscriptionStatus = subscription.attrib.get('status') + self.subscriptionPlan = subscription.attrib.get('plan') + self.subscriptionFeatures = self.listAttrs(subscription, 'id', etag='feature') + + self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role') + + self.entitlements = self.listAttrs(data, 'id', rtag='entitlements', etag='entitlement') + + # TODO: Fetch missing MyPlexAccount attributes + self.profile_settings = None + self.services = None + self.joined_at = None + + def device(self, name=None, clientId=None): + """ Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified. + + Parameters: + name (str): Name to match against. + clientId (str): clientIdentifier to match against. + """ + for device in self.devices(): + if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId): + return device + raise NotFound('Unable to find device %s' % name) + + def devices(self): + """ Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """ + data = self.query(MyPlexDevice.key) + return [MyPlexDevice(self, elem) for elem in data] + + def _headers(self, **kwargs): + """ Returns dict containing base headers for all requests to the server. """ + headers = BASE_HEADERS.copy() + if self._token: + headers['X-Plex-Token'] = self._token + headers.update(kwargs) + return headers + + def query(self, url, method=None, headers=None, timeout=None, **kwargs): + method = method or self._session.get + timeout = timeout or TIMEOUT + log.debug('%s %s %s', method.__name__.upper(), url, kwargs.get('json', '')) + headers = self._headers(**headers or {}) + response = method(url, headers=headers, timeout=timeout, **kwargs) + if response.status_code not in (200, 201, 204): # pragma: no cover + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) + if response.status_code == 401: + raise Unauthorized(message) + elif response.status_code == 404: + raise NotFound(message) + else: + raise BadRequest(message) + data = response.text.encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None + + def resource(self, name): + """ Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified. + + Parameters: + name (str): Name to match against. + """ + for resource in self.resources(): + if resource.name.lower() == name.lower(): + return resource + raise NotFound('Unable to find resource %s' % name) + + def resources(self): + """ Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """ + data = self.query(MyPlexResource.key) + return [MyPlexResource(self, elem) for elem in data] + + def sonos_speakers(self): + if 'companions_sonos' not in self.subscriptionFeatures: + return [] + + t = time.time() + if t - self._sonos_cache_timestamp > 5: + self._sonos_cache_timestamp = t + data = self.query('https://sonos.plex.tv/resources') + self._sonos_cache = [PlexSonosClient(self, elem) for elem in data] + + return self._sonos_cache + + def sonos_speaker(self, name): + return next((x for x in self.sonos_speakers() if x.title.split("+")[0].strip() == name), None) + + def sonos_speaker_by_id(self, identifier): + return next((x for x in self.sonos_speakers() if x.machineIdentifier.startswith(identifier)), None) + + def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Share library content with the specified user. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be added. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: `{'label':['foo']}` + """ + username = user.username if isinstance(user, MyPlexUser) else user + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(machineId, sections) + params = { + 'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_email': username}, + 'sharing_settings': { + 'allowSync': ('1' if allowSync else '0'), + 'allowCameraUpload': ('1' if allowCameraUpload else '0'), + 'allowChannels': ('1' if allowChannels else '0'), + 'filterMovies': self._filterDictToStr(filterMovies or {}), + 'filterTelevision': self._filterDictToStr(filterTelevision or {}), + 'filterMusic': self._filterDictToStr(filterMusic or {}), + }, + } + headers = {'Content-Type': 'application/json'} + url = self.FRIENDINVITE.format(machineId=machineId) + return self.query(url, self._session.post, json=params, headers=headers) + + def createHomeUser(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Share library content with the specified user. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be added. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: `{'label':['foo']}` + """ + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(server, sections) + + headers = {'Content-Type': 'application/json'} + url = self.HOMEUSERCREATE.format(title=user) + # UserID needs to be created and referenced when adding sections + user_creation = self.query(url, self._session.post, headers=headers) + userIds = {} + for elem in user_creation.findall("."): + # Find userID + userIds['id'] = elem.attrib.get('id') + log.debug(userIds) + params = { + 'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_id': userIds['id']}, + 'sharing_settings': { + 'allowSync': ('1' if allowSync else '0'), + 'allowCameraUpload': ('1' if allowCameraUpload else '0'), + 'allowChannels': ('1' if allowChannels else '0'), + 'filterMovies': self._filterDictToStr(filterMovies or {}), + 'filterTelevision': self._filterDictToStr(filterTelevision or {}), + 'filterMusic': self._filterDictToStr(filterMusic or {}), + }, + } + url = self.FRIENDINVITE.format(machineId=machineId) + library_assignment = self.query(url, self._session.post, json=params, headers=headers) + return user_creation, library_assignment + + def createExistingUser(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Share library content with the specified user. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be added. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: `{'label':['foo']}` + """ + headers = {'Content-Type': 'application/json'} + # If user already exists, carry over sections and settings. + if isinstance(user, MyPlexUser): + username = user.username + elif user in [_user.username for _user in self.users()]: + username = self.user(user).username + else: + # If user does not already exists, treat request as new request and include sections and settings. + newUser = user + url = self.EXISTINGUSER.format(username=newUser) + user_creation = self.query(url, self._session.post, headers=headers) + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(server, sections) + params = { + 'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_email': newUser}, + 'sharing_settings': { + 'allowSync': ('1' if allowSync else '0'), + 'allowCameraUpload': ('1' if allowCameraUpload else '0'), + 'allowChannels': ('1' if allowChannels else '0'), + 'filterMovies': self._filterDictToStr(filterMovies or {}), + 'filterTelevision': self._filterDictToStr(filterTelevision or {}), + 'filterMusic': self._filterDictToStr(filterMusic or {}), + }, + } + url = self.FRIENDINVITE.format(machineId=machineId) + library_assignment = self.query(url, self._session.post, json=params, headers=headers) + return user_creation, library_assignment + + url = self.EXISTINGUSER.format(username=username) + return self.query(url, self._session.post, headers=headers) + + def removeFriend(self, user): + """ Remove the specified user from your friends. + + Parameters: + user (str): :class:`~plexapi.myplex.MyPlexUser`, username, or email of the user to be removed. + """ + user = user if isinstance(user, MyPlexUser) else self.user(user) + url = self.FRIENDUPDATE.format(userId=user.id) + return self.query(url, self._session.delete) + + def removeHomeUser(self, user): + """ Remove the specified user from your home users. + + Parameters: + user (str): :class:`~plexapi.myplex.MyPlexUser`, username, or email of the user to be removed. + """ + user = user if isinstance(user, MyPlexUser) else self.user(user) + url = self.REMOVEHOMEUSER.format(userId=user.id) + return self.query(url, self._session.delete) + + def acceptInvite(self, user): + """ Accept a pending firend invite from the specified user. + + Parameters: + user (str): :class:`~plexapi.myplex.MyPlexInvite`, username, or email of the friend invite to accept. + """ + invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeSent=False) + params = { + 'friend': int(invite.friend), + 'home': int(invite.home), + 'server': int(invite.server) + } + url = MyPlexInvite.REQUESTS + '/%s' % invite.id + utils.joinArgs(params) + return self.query(url, self._session.put) + + def cancelInvite(self, user): + """ Cancel a pending firend invite for the specified user. + + Parameters: + user (str): :class:`~plexapi.myplex.MyPlexInvite`, username, or email of the friend invite to cancel. + """ + invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeReceived=False) + params = { + 'friend': int(invite.friend), + 'home': int(invite.home), + 'server': int(invite.server) + } + url = MyPlexInvite.REQUESTED + '/%s' % invite.id + utils.joinArgs(params) + return self.query(url, self._session.delete) + + def updateFriend(self, user, server, sections=None, removeSections=False, allowSync=None, allowCameraUpload=None, + allowChannels=None, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Update the specified user's share settings. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be updated. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. + removeSections (Bool): Set True to remove all shares. Supersedes sections. + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: `{'label':['foo']}` + """ + # Update friend servers + response_filters = '' + response_servers = '' + user = user if isinstance(user, MyPlexUser) else self.user(user) + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(machineId, sections) + headers = {'Content-Type': 'application/json'} + # Determine whether user has access to the shared server. + user_servers = [s for s in user.servers if s.machineIdentifier == machineId] + if user_servers and sectionIds: + serverId = user_servers[0].id + params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}} + url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId) + else: + params = {'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_id': user.id}} + url = self.FRIENDINVITE.format(machineId=machineId) + # Remove share sections, add shares to user without shares, or update shares + if not user_servers or sectionIds: + if removeSections is True: + response_servers = self.query(url, self._session.delete, json=params, headers=headers) + elif 'invited_id' in params.get('shared_server', ''): + response_servers = self.query(url, self._session.post, json=params, headers=headers) + else: + response_servers = self.query(url, self._session.put, json=params, headers=headers) + else: + log.warning('Section name, number of section object is required changing library sections') + # Update friend filters + url = self.FRIENDUPDATE.format(userId=user.id) + params = {} + if isinstance(allowSync, bool): + params['allowSync'] = '1' if allowSync else '0' + if isinstance(allowCameraUpload, bool): + params['allowCameraUpload'] = '1' if allowCameraUpload else '0' + if isinstance(allowChannels, bool): + params['allowChannels'] = '1' if allowChannels else '0' + if isinstance(filterMovies, dict): + params['filterMovies'] = self._filterDictToStr(filterMovies or {}) # '1' if allowChannels else '0' + if isinstance(filterTelevision, dict): + params['filterTelevision'] = self._filterDictToStr(filterTelevision or {}) + if isinstance(allowChannels, dict): + params['filterMusic'] = self._filterDictToStr(filterMusic or {}) + if params: + url += utils.joinArgs(params) + response_filters = self.query(url, self._session.put) + return response_servers, response_filters + + def user(self, username): + """ Returns the :class:`~plexapi.myplex.MyPlexUser` that matches the specified username or email. + + Parameters: + username (str): Username, email or id of the user to return. + """ + username = str(username) + for user in self.users(): + # Home users don't have email, username etc. + if username.lower() == user.title.lower(): + return user + + elif (user.username and user.email and user.id and username.lower() in + (user.username.lower(), user.email.lower(), str(user.id))): + return user + + raise NotFound('Unable to find user %s' % username) + + def users(self): + """ Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account. + """ + elem = self.query(MyPlexUser.key) + return self.findItems(elem, cls=MyPlexUser) + + def pendingInvite(self, username, includeSent=True, includeReceived=True): + """ Returns the :class:`~plexapi.myplex.MyPlexInvite` that matches the specified username or email. + Note: This can be a pending invite sent from your account or received to your account. + + Parameters: + username (str): Username, email or id of the user to return. + includeSent (bool): True to include sent invites. + includeReceived (bool): True to include received invites. + """ + username = str(username) + for invite in self.pendingInvites(includeSent, includeReceived): + if (invite.username and invite.email and invite.id and username.lower() in + (invite.username.lower(), invite.email.lower(), str(invite.id))): + return invite + + raise NotFound('Unable to find invite %s' % username) + + def pendingInvites(self, includeSent=True, includeReceived=True): + """ Returns a list of all :class:`~plexapi.myplex.MyPlexInvite` objects connected to your account. + Note: This includes all pending invites sent from your account and received to your account. + + Parameters: + includeSent (bool): True to include sent invites. + includeReceived (bool): True to include received invites. + """ + invites = [] + if includeSent: + elem = self.query(MyPlexInvite.REQUESTED) + invites += self.findItems(elem, cls=MyPlexInvite) + if includeReceived: + elem = self.query(MyPlexInvite.REQUESTS) + invites += self.findItems(elem, cls=MyPlexInvite) + return invites + + def _getSectionIds(self, server, sections): + """ Converts a list of section objects or names to sectionIds needed for library sharing. """ + if not sections: return [] + # Get a list of all section ids for looking up each section. + allSectionIds = {} + machineIdentifier = server.machineIdentifier if isinstance(server, PlexServer) else server + url = self.PLEXSERVERS.replace('{machineId}', machineIdentifier) + data = self.query(url, self._session.get) + for elem in data[0]: + _id = utils.cast(int, elem.attrib.get('id')) + _key = utils.cast(int, elem.attrib.get('key')) + _title = elem.attrib.get('title', '').lower() + allSectionIds[_id] = _id + allSectionIds[_key] = _id + allSectionIds[_title] = _id + log.debug(allSectionIds) + # Convert passed in section items to section ids from above lookup + sectionIds = [] + for section in sections: + sectionKey = section.key if isinstance(section, LibrarySection) else section.lower() + sectionIds.append(allSectionIds[sectionKey]) + return sectionIds + + def _filterDictToStr(self, filterDict): + """ Converts friend filters to a string representation for transport. """ + values = [] + for key, vals in list(filterDict.items()): + if key not in ('contentRating', 'label'): + raise BadRequest('Unknown filter key: %s', key) + values.append('%s=%s' % (key, '%2C'.join(vals))) + return '|'.join(values) + + def addWebhook(self, url): + # copy _webhooks and append url + urls = self._webhooks[:] + [url] + return self.setWebhooks(urls) + + def deleteWebhook(self, url): + urls = copy.copy(self._webhooks) + if url not in urls: + raise BadRequest('Webhook does not exist: %s' % url) + urls.remove(url) + return self.setWebhooks(urls) + + def setWebhooks(self, urls): + log.info('Setting webhooks: %s' % urls) + data = {'urls[]': urls} if len(urls) else {'urls': ''} + data = self.query(self.WEBHOOKS, self._session.post, data=data) + self._webhooks = self.listAttrs(data, 'url', etag='webhook') + return self._webhooks + + def webhooks(self): + data = self.query(self.WEBHOOKS) + self._webhooks = self.listAttrs(data, 'url', etag='webhook') + return self._webhooks + + def optOut(self, playback=None, library=None): + """ Opt in or out of sharing stuff with plex. + See: https://www.plex.tv/about/privacy-legal/ + """ + params = {} + if playback is not None: + params['optOutPlayback'] = int(playback) + if library is not None: + params['optOutLibraryStats'] = int(library) + url = 'https://plex.tv/api/v2/user/privacy' + return self.query(url, method=self._session.put, data=params) + + def syncItems(self, client=None, clientId=None): + """ Returns an instance of :class:`~plexapi.sync.SyncList` for specified client. + + Parameters: + client (:class:`~plexapi.myplex.MyPlexDevice`): a client to query SyncItems for. + clientId (str): an identifier of a client to query SyncItems for. + + If both `client` and `clientId` provided the client would be preferred. + If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier. + """ + if client: + clientId = client.clientIdentifier + elif clientId is None: + clientId = X_PLEX_IDENTIFIER + + data = self.query(SyncList.key.format(clientId=clientId)) + + return SyncList(self, data) + + def sync(self, sync_item, client=None, clientId=None): + """ Adds specified sync item for the client. It's always easier to use methods defined directly in the media + objects, e.g. :func:`~plexapi.video.Video.sync`, :func:`~plexapi.audio.Audio.sync`. + + Parameters: + client (:class:`~plexapi.myplex.MyPlexDevice`): a client for which you need to add SyncItem to. + clientId (str): an identifier of a client for which you need to add SyncItem to. + sync_item (:class:`~plexapi.sync.SyncItem`): prepared SyncItem object with all fields set. + + If both `client` and `clientId` provided the client would be preferred. + If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When client with provided clientId wasn`t found. + :exc:`~plexapi.exceptions.BadRequest`: Provided client doesn`t provides `sync-target`. + """ + if not client and not clientId: + clientId = X_PLEX_IDENTIFIER + + if not client: + for device in self.devices(): + if device.clientIdentifier == clientId: + client = device + break + + if not client: + raise BadRequest('Unable to find client by clientId=%s', clientId) + + if 'sync-target' not in client.provides: + raise BadRequest('Received client doesn`t provides sync-target') + + params = { + 'SyncItem[title]': sync_item.title, + 'SyncItem[rootTitle]': sync_item.rootTitle, + 'SyncItem[metadataType]': sync_item.metadataType, + 'SyncItem[machineIdentifier]': sync_item.machineIdentifier, + 'SyncItem[contentType]': sync_item.contentType, + 'SyncItem[Policy][scope]': sync_item.policy.scope, + 'SyncItem[Policy][unwatched]': str(int(sync_item.policy.unwatched)), + 'SyncItem[Policy][value]': str(sync_item.policy.value if hasattr(sync_item.policy, 'value') else 0), + 'SyncItem[Location][uri]': sync_item.location, + 'SyncItem[MediaSettings][audioBoost]': str(sync_item.mediaSettings.audioBoost), + 'SyncItem[MediaSettings][maxVideoBitrate]': str(sync_item.mediaSettings.maxVideoBitrate), + 'SyncItem[MediaSettings][musicBitrate]': str(sync_item.mediaSettings.musicBitrate), + 'SyncItem[MediaSettings][photoQuality]': str(sync_item.mediaSettings.photoQuality), + 'SyncItem[MediaSettings][photoResolution]': sync_item.mediaSettings.photoResolution, + 'SyncItem[MediaSettings][subtitleSize]': str(sync_item.mediaSettings.subtitleSize), + 'SyncItem[MediaSettings][videoQuality]': str(sync_item.mediaSettings.videoQuality), + 'SyncItem[MediaSettings][videoResolution]': sync_item.mediaSettings.videoResolution, + } + + url = SyncList.key.format(clientId=client.clientIdentifier) + data = self.query(url, method=self._session.post, params=params) + + return SyncItem(self, data, None, clientIdentifier=client.clientIdentifier) + + def claimToken(self): + """ Returns a str, a new "claim-token", which you can use to register your new Plex Server instance to your + account. + See: https://hub.docker.com/r/plexinc/pms-docker/, https://www.plex.tv/claim/ + """ + response = self._session.get('https://plex.tv/api/claim/token.json', headers=self._headers(), timeout=TIMEOUT) + if response.status_code not in (200, 201, 204): # pragma: no cover + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + return response.json()['token'] + + def history(self, maxresults=9999999, mindate=None): + """ Get Play History for all library sections on all servers for the owner. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + servers = [x for x in self.resources() if x.provides == 'server' and x.owned] + hist = [] + for server in servers: + conn = server.connect() + hist.extend(conn.history(maxresults=maxresults, mindate=mindate, accountID=1)) + return hist + + def videoOnDemand(self): + """ Returns a list of VOD Hub items :class:`~plexapi.library.Hub` + """ + req = requests.get(self.VOD + 'hubs/', headers={'X-Plex-Token': self._token}) + elem = ElementTree.fromstring(req.text) + return self.findItems(elem) + + def webShows(self): + """ Returns a list of Webshow Hub items :class:`~plexapi.library.Hub` + """ + req = requests.get(self.WEBSHOWS + 'hubs/', headers={'X-Plex-Token': self._token}) + elem = ElementTree.fromstring(req.text) + return self.findItems(elem) + + def news(self): + """ Returns a list of News Hub items :class:`~plexapi.library.Hub` + """ + req = requests.get(self.NEWS + 'hubs/sections/all', headers={'X-Plex-Token': self._token}) + elem = ElementTree.fromstring(req.text) + return self.findItems(elem) + + def podcasts(self): + """ Returns a list of Podcasts Hub items :class:`~plexapi.library.Hub` + """ + req = requests.get(self.PODCASTS + 'hubs/', headers={'X-Plex-Token': self._token}) + elem = ElementTree.fromstring(req.text) + return self.findItems(elem) + + def tidal(self): + """ Returns a list of tidal Hub items :class:`~plexapi.library.Hub` + """ + req = requests.get(self.MUSIC + 'hubs/', headers={'X-Plex-Token': self._token}) + elem = ElementTree.fromstring(req.text) + return self.findItems(elem) + + def onlineMediaSources(self): + """ Returns a list of user account Online Media Sources settings :class:`~plexapi.myplex.AccountOptOut` + """ + url = self.OPTOUTS % {'userUUID': self.uuid} + elem = self.query(url) + return self.findItems(elem, cls=AccountOptOut, etag='optOut') + + def link(self, pin): + """ Link a device to the account using a pin code. + + Parameters: + pin (str): The 4 digit link pin code. + """ + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Plex-Product': 'Plex SSO' + } + data = {'code': pin} + self.query(self.LINK, self._session.put, headers=headers, data=data) + + +class MyPlexUser(PlexObject): + """ This object represents non-signed in users such as friends and linked + accounts. NOTE: This should not be confused with the :class:`~plexapi.myplex.MyPlexAccount` + which is your specific account. The raw xml for the data presented here + can be found at: https://plex.tv/api/users/ + + Attributes: + TAG (str): 'User' + key (str): 'https://plex.tv/api/users/' + allowCameraUpload (bool): True if this user can upload images. + allowChannels (bool): True if this user has access to channels. + allowSync (bool): True if this user can sync. + email (str): User's email address (user@gmail.com). + filterAll (str): Unknown. + filterMovies (str): Unknown. + filterMusic (str): Unknown. + filterPhotos (str): Unknown. + filterTelevision (str): Unknown. + home (bool): Unknown. + id (int): User's Plex account ID. + protected (False): Unknown (possibly SSL enabled?). + recommendationsPlaylistId (str): Unknown. + restricted (str): Unknown. + servers (List<:class:`~plexapi.myplex.<MyPlexServerShare`>)): Servers shared with the user. + thumb (str): Link to the users avatar. + title (str): Seems to be an aliad for username. + username (str): User's username. + """ + TAG = 'User' + key = 'https://plex.tv/api/users/' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.friend = self._initpath == self.key + self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) + self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels')) + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.email = data.attrib.get('email') + self.filterAll = data.attrib.get('filterAll') + self.filterMovies = data.attrib.get('filterMovies') + self.filterMusic = data.attrib.get('filterMusic') + self.filterPhotos = data.attrib.get('filterPhotos') + self.filterTelevision = data.attrib.get('filterTelevision') + self.home = utils.cast(bool, data.attrib.get('home')) + self.id = utils.cast(int, data.attrib.get('id')) + self.protected = utils.cast(bool, data.attrib.get('protected')) + self.recommendationsPlaylistId = data.attrib.get('recommendationsPlaylistId') + self.restricted = data.attrib.get('restricted') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title', '') + self.username = data.attrib.get('username', '') + self.servers = self.findItems(data, MyPlexServerShare) + for server in self.servers: + server.accountID = self.id + + def get_token(self, machineIdentifier): + try: + for item in self._server.query(self._server.FRIENDINVITE.format(machineId=machineIdentifier)): + if utils.cast(int, item.attrib.get('userID')) == self.id: + return item.attrib.get('accessToken') + except Exception: + log.exception('Failed to get access token for %s' % self.title) + + def server(self, name): + """ Returns the :class:`~plexapi.myplex.MyPlexServerShare` that matches the name specified. + + Parameters: + name (str): Name of the server to return. + """ + for server in self.servers: + if name.lower() == server.name.lower(): + return server + + raise NotFound('Unable to find server %s' % name) + + def history(self, maxresults=9999999, mindate=None): + """ Get all Play History for a user in all shared servers. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + hist = [] + for server in self.servers: + hist.extend(server.history(maxresults=maxresults, mindate=mindate)) + return hist + + +class MyPlexInvite(PlexObject): + """ This object represents pending friend invites. + + Attributes: + TAG (str): 'Invite' + createdAt (datetime): Datetime the user was invited. + email (str): User's email address (user@gmail.com). + friend (bool): True or False if the user is invited as a friend. + friendlyName (str): The user's friendly name. + home (bool): True or False if the user is invited to a Plex Home. + id (int): User's Plex account ID. + server (bool): True or False if the user is invited to any servers. + servers (List<:class:`~plexapi.myplex.<MyPlexServerShare`>)): Servers shared with the user. + thumb (str): Link to the users avatar. + username (str): User's username. + """ + TAG = 'Invite' + REQUESTS = 'https://plex.tv/api/invites/requests' + REQUESTED = 'https://plex.tv/api/invites/requested' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.email = data.attrib.get('email') + self.friend = utils.cast(bool, data.attrib.get('friend')) + self.friendlyName = data.attrib.get('friendlyName') + self.home = utils.cast(bool, data.attrib.get('home')) + self.id = utils.cast(int, data.attrib.get('id')) + self.server = utils.cast(bool, data.attrib.get('server')) + self.servers = self.findItems(data, MyPlexServerShare) + self.thumb = data.attrib.get('thumb') + self.username = data.attrib.get('username', '') + for server in self.servers: + server.accountID = self.id + + +class Section(PlexObject): + """ This refers to a shared section. The raw xml for the data presented here + can be found at: https://plex.tv/api/servers/{machineId}/shared_servers + + Attributes: + TAG (str): section + id (int): The shared section ID + key (int): The shared library section key + shared (bool): If this section is shared with the user + title (str): Title of the section + type (str): movie, tvshow, artist + + """ + TAG = 'Section' + + def _loadData(self, data): + self._data = data + self.id = utils.cast(int, data.attrib.get('id')) + self.key = utils.cast(int, data.attrib.get('key')) + self.shared = utils.cast(bool, data.attrib.get('shared', '0')) + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.sectionId = self.id # For backwards compatibility + self.sectionKey = self.key # For backwards compatibility + + def history(self, maxresults=9999999, mindate=None): + """ Get all Play History for a user for this section in this shared server. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + server = self._server._server.resource(self._server.name).connect() + return server.history(maxresults=maxresults, mindate=mindate, + accountID=self._server.accountID, librarySectionID=self.sectionKey) + + +class MyPlexServerShare(PlexObject): + """ Represents a single user's server reference. Used for library sharing. + + Attributes: + id (int): id for this share + serverId (str): what id plex uses for this. + machineIdentifier (str): The servers machineIdentifier + name (str): The servers name + lastSeenAt (datetime): Last connected to the server? + numLibraries (int): Total number of libraries + allLibraries (bool): True if all libraries is shared with this user. + owned (bool): 1 if the server is owned by the user + pending (bool): True if the invite is pending. + + """ + TAG = 'Server' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.id = utils.cast(int, data.attrib.get('id')) + self.accountID = utils.cast(int, data.attrib.get('accountID')) + self.serverId = utils.cast(int, data.attrib.get('serverId')) + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.name = data.attrib.get('name') + self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) + self.numLibraries = utils.cast(int, data.attrib.get('numLibraries')) + self.allLibraries = utils.cast(bool, data.attrib.get('allLibraries')) + self.owned = utils.cast(bool, data.attrib.get('owned')) + self.pending = utils.cast(bool, data.attrib.get('pending')) + + def section(self, name): + """ Returns the :class:`~plexapi.myplex.Section` that matches the name specified. + + Parameters: + name (str): Name of the section to return. + """ + for section in self.sections(): + if name.lower() == section.title.lower(): + return section + + raise NotFound('Unable to find section %s' % name) + + def sections(self): + """ Returns a list of all :class:`~plexapi.myplex.Section` objects shared with this user. + """ + url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id) + data = self._server.query(url) + return self.findItems(data, Section, rtag='SharedServer') + + def history(self, maxresults=9999999, mindate=None): + """ Get all Play History for a user in this shared server. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + server = self._server.resource(self.name).connect() + return server.history(maxresults=maxresults, mindate=mindate, accountID=self.accountID) + + +class MyPlexResource(PlexObject): + """ This object represents resources connected to your Plex server that can provide + content such as Plex Media Servers, iPhone or Android clients, etc. The raw xml + for the data presented here can be found at: + https://plex.tv/api/resources?includeHttps=1&includeRelay=1 + + Attributes: + TAG (str): 'Device' + key (str): 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1' + accessToken (str): This resources accesstoken. + clientIdentifier (str): Unique ID for this resource. + connections (list): List of :class:`~plexapi.myplex.ResourceConnection` objects + for this resource. + createdAt (datetime): Timestamp this resource first connected to your server. + device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc). + home (bool): Unknown + lastSeenAt (datetime): Timestamp this resource last connected. + name (str): Descriptive name of this resource. + owned (bool): True if this resource is one of your own (you logged into it). + platform (str): OS the resource is running (Linux, Windows, Chrome, etc.) + platformVersion (str): Version of the platform. + presence (bool): True if the resource is online + product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.) + productVersion (str): Version of the product. + provides (str): List of services this resource provides (client, server, + player, pubsub-player, etc.) + synced (bool): Unknown (possibly True if the resource has synced content?) + """ + TAG = 'Device' + key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1' + + # Default order to prioritize available resource connections + DEFAULT_LOCATION_ORDER = ['local', 'remote', 'relay'] + DEFAULT_SCHEME_ORDER = ['https', 'http'] + + def _loadData(self, data): + self._data = data + self.name = data.attrib.get('name') + self.accessToken = logfilter.add_secret(data.attrib.get('accessToken')) + self.product = data.attrib.get('product') + self.productVersion = data.attrib.get('productVersion') + self.platform = data.attrib.get('platform') + self.platformVersion = data.attrib.get('platformVersion') + self.device = data.attrib.get('device') + self.clientIdentifier = data.attrib.get('clientIdentifier') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) + self.provides = data.attrib.get('provides') + self.owned = utils.cast(bool, data.attrib.get('owned')) + self.home = utils.cast(bool, data.attrib.get('home')) + self.synced = utils.cast(bool, data.attrib.get('synced')) + self.presence = utils.cast(bool, data.attrib.get('presence')) + self.connections = self.findItems(data, ResourceConnection) + self.publicAddressMatches = utils.cast(bool, data.attrib.get('publicAddressMatches')) + # This seems to only be available if its not your device (say are shared server) + self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired')) + self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0)) + self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username. + + def preferred_connections( + self, + ssl=None, + timeout=None, + locations=DEFAULT_LOCATION_ORDER, + schemes=DEFAULT_SCHEME_ORDER, + ): + """ Returns a sorted list of the available connection addresses for this resource. + Often times there is more than one address specified for a server or client. + Default behavior will prioritize local connections before remote or relay and HTTPS before HTTP. + + Parameters: + ssl (bool, optional): Set True to only connect to HTTPS connections. Set False to + only connect to HTTP connections. Set None (default) to connect to any + HTTP or HTTPS connection. + timeout (int, optional): The timeout in seconds to attempt each connection. + """ + connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations} + for connection in self.connections: + # Only check non-local connections unless we own the resource + if self.owned or (not self.owned and not connection.local): + location = 'relay' if connection.relay else ('local' if connection.local else 'remote') + if location not in locations: + continue + if 'http' in schemes: + connections_dict[location]['http'].append(connection.httpuri) + if 'https' in schemes: + connections_dict[location]['https'].append(connection.uri) + if ssl is True: schemes.remove('http') + elif ssl is False: schemes.remove('https') + connections = [] + for location in locations: + for scheme in schemes: + connections.extend(connections_dict[location][scheme]) + return connections + + def connect( + self, + ssl=None, + timeout=None, + locations=DEFAULT_LOCATION_ORDER, + schemes=DEFAULT_SCHEME_ORDER, + ): + """ Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object. + Uses `MyPlexResource.preferred_connections()` to generate the priority order of connection addresses. + After trying to connect to all available addresses for this resource and + assuming at least one connection was successful, the PlexServer object is built and returned. + + Parameters: + ssl (bool, optional): Set True to only connect to HTTPS connections. Set False to + only connect to HTTP connections. Set None (default) to connect to any + HTTP or HTTPS connection. + timeout (int, optional): The timeout in seconds to attempt each connection. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. + """ + connections = self.preferred_connections(ssl, timeout, locations, schemes) + # Try connecting to all known resource connections in parellel, but + # only return the first server (in order) that provides a response. + cls = PlexServer if 'server' in self.provides else PlexClient + listargs = [[cls, url, self.accessToken, timeout] for url in connections] + log.debug('Testing %s resource connections..', len(listargs)) + results = utils.threaded(_connect, listargs) + return _chooseConnection('Resource', self.name, results) + + +class ResourceConnection(PlexObject): + """ Represents a Resource Connection object found within the + :class:`~plexapi.myplex.MyPlexResource` objects. + + Attributes: + TAG (str): 'Connection' + address (str): Local IP address + httpuri (str): Full local address + local (bool): True if local + port (int): 32400 + protocol (str): HTTP or HTTPS + uri (str): External address + """ + TAG = 'Connection' + + def _loadData(self, data): + self._data = data + self.protocol = data.attrib.get('protocol') + self.address = data.attrib.get('address') + self.port = utils.cast(int, data.attrib.get('port')) + self.uri = data.attrib.get('uri') + self.local = utils.cast(bool, data.attrib.get('local')) + self.httpuri = 'http://%s:%s' % (self.address, self.port) + self.relay = utils.cast(bool, data.attrib.get('relay')) + + +class MyPlexDevice(PlexObject): + """ This object represents resources connected to your Plex server that provide + playback ability from your Plex Server, iPhone or Android clients, Plex Web, + this API, etc. The raw xml for the data presented here can be found at: + https://plex.tv/devices.xml + + Attributes: + TAG (str): 'Device' + key (str): 'https://plex.tv/devices.xml' + clientIdentifier (str): Unique ID for this resource. + connections (list): List of connection URIs for the device. + device (str): Best guess on the type of device this is (Linux, iPad, AFTB, etc). + id (str): MyPlex ID of the device. + model (str): Model of the device (bueller, Linux, x86_64, etc.) + name (str): Hostname of the device. + platform (str): OS the resource is running (Linux, Windows, Chrome, etc.) + platformVersion (str): Version of the platform. + product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.) + productVersion (string): Version of the product. + provides (str): List of services this resource provides (client, controller, + sync-target, player, pubsub-player). + publicAddress (str): Public IP address. + screenDensity (str): Unknown + screenResolution (str): Screen resolution (750x1334, 1242x2208, etc.) + token (str): Plex authentication token for the device. + vendor (str): Device vendor (ubuntu, etc). + version (str): Unknown (1, 2, 1.3.3.3148-b38628e, 1.3.15, etc.) + """ + TAG = 'Device' + key = 'https://plex.tv/devices.xml' + + def _loadData(self, data): + self._data = data + self.name = data.attrib.get('name') + self.publicAddress = data.attrib.get('publicAddress') + self.product = data.attrib.get('product') + self.productVersion = data.attrib.get('productVersion') + self.platform = data.attrib.get('platform') + self.platformVersion = data.attrib.get('platformVersion') + self.device = data.attrib.get('device') + self.model = data.attrib.get('model') + self.vendor = data.attrib.get('vendor') + self.provides = data.attrib.get('provides') + self.clientIdentifier = data.attrib.get('clientIdentifier') + self.version = data.attrib.get('version') + self.id = data.attrib.get('id') + self.token = logfilter.add_secret(data.attrib.get('token')) + self.screenResolution = data.attrib.get('screenResolution') + self.screenDensity = data.attrib.get('screenDensity') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) + self.connections = self.listAttrs(data, 'uri', etag='Connection') + + def connect(self, timeout=None): + """ Returns a new :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer` + Sometimes there is more than one address specified for a server or client. + After trying to connect to all available addresses for this client and assuming + at least one connection was successful, the PlexClient object is built and returned. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device. + """ + cls = PlexServer if 'server' in self.provides else PlexClient + listargs = [[cls, url, self.token, timeout] for url in self.connections] + log.debug('Testing %s device connections..', len(listargs)) + results = utils.threaded(_connect, listargs) + return _chooseConnection('Device', self.name, results) + + def delete(self): + """ Remove this device from your account. """ + key = 'https://plex.tv/devices/%s.xml' % self.id + self._server.query(key, self._server._session.delete) + + def syncItems(self): + """ Returns an instance of :class:`~plexapi.sync.SyncList` for current device. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: when the device doesn`t provides `sync-target`. + """ + if 'sync-target' not in self.provides: + raise BadRequest('Requested syncList for device which do not provides sync-target') + + return self._server.syncItems(client=self) + + +class MyPlexPinLogin(object): + """ + MyPlex PIN login class which supports getting the four character PIN which the user must + enter on https://plex.tv/link to authenticate the client and provide an access token to + create a :class:`~plexapi.myplex.MyPlexAccount` instance. + This helper class supports a polling, threaded and callback approach. + + - The polling approach expects the developer to periodically check if the PIN login was + successful using :func:`~plexapi.myplex.MyPlexPinLogin.checkLogin`. + - The threaded approach expects the developer to call + :func:`~plexapi.myplex.MyPlexPinLogin.run` and then at a later time call + :func:`~plexapi.myplex.MyPlexPinLogin.waitForLogin` to wait for and check the result. + - The callback approach is an extension of the threaded approach and expects the developer + to pass the `callback` parameter to the call to :func:`~plexapi.myplex.MyPlexPinLogin.run`. + The callback will be called when the thread waiting for the PIN login to succeed either + finishes or expires. The parameter passed to the callback is the received authentication + token or `None` if the login expired. + + Parameters: + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from PMS + requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT). + + Attributes: + PINS (str): 'https://plex.tv/api/v2/pins' + CHECKPINS (str): 'https://plex.tv/api/v2/pins/{pinid}' + LINK (str): 'https://plex.tv/api/v2/pins/link' + POLLINTERVAL (int): 1 + finished (bool): Whether the pin login has finished or not. + expired (bool): Whether the pin login has expired or not. + token (str): Token retrieved through the pin login. + pin (str): Pin to use for the login on https://plex.tv/link. + """ + PINS = 'https://plex.tv/api/v2/pins' # get + CHECKPINS = 'https://plex.tv/api/v2/pins/{pinid}' # get + POLLINTERVAL = 1 + + def __init__(self, session=None, requestTimeout=None, headers=None): + super(MyPlexPinLogin, self).__init__() + self._session = session or requests.Session() + self._requestTimeout = requestTimeout or TIMEOUT + self.headers = headers + + self._loginTimeout = None + self._callback = None + self._thread = None + self._abort = False + self._id = None + self._code = None + self._getCode() + + self.finished = False + self.expired = False + self.token = None + + @property + def pin(self): + return self._code + + def run(self, callback=None, timeout=None): + """ Starts the thread which monitors the PIN login state. + Parameters: + callback (Callable[str]): Callback called with the received authentication token (optional). + timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional). + + Raises: + :class:`RuntimeError`: If the thread is already running. + :class:`RuntimeError`: If the PIN login for the current PIN has expired. + """ + if self._thread and not self._abort: + raise RuntimeError('MyPlexPinLogin thread is already running') + if self.expired: + raise RuntimeError('MyPlexPinLogin has expired') + + self._loginTimeout = timeout + self._callback = callback + self._abort = False + self.finished = False + self._thread = threading.Thread(target=self._pollLogin, name='plexapi.myplex.MyPlexPinLogin') + self._thread.start() + + def waitForLogin(self): + """ Waits for the PIN login to succeed or expire. + Parameters: + callback (Callable[str]): Callback called with the received authentication token (optional). + timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional). + + Returns: + `True` if the PIN login succeeded or `False` otherwise. + """ + if not self._thread or self._abort: + return False + + self._thread.join() + if self.expired or not self.token: + return False + + return True + + def stop(self): + """ Stops the thread monitoring the PIN login state. """ + if not self._thread or self._abort: + return + + self._abort = True + self._thread.join() + + def checkLogin(self): + """ Returns `True` if the PIN login has succeeded. """ + if self._thread: + return False + + try: + return self._checkLogin() + except Exception: + self.expired = True + self.finished = True + + return False + + def _getCode(self): + url = self.PINS + response = self._query(url, self._session.post) + if not response: + return None + + self._id = response.attrib.get('id') + self._code = response.attrib.get('code') + + return self._code + + def _checkLogin(self): + if not self._id: + return False + + if self.token: + return True + + url = self.CHECKPINS.format(pinid=self._id) + response = self._query(url) + if not response: + return False + + token = response.attrib.get('authToken') + if not token: + return False + + self.token = token + self.finished = True + return True + + def _pollLogin(self): + try: + start = time.time() + while not self._abort and (not self._loginTimeout or (time.time() - start) < self._loginTimeout): + try: + result = self._checkLogin() + except Exception: + self.expired = True + break + + if result: + break + + time.sleep(self.POLLINTERVAL) + + if self.token and self._callback: + self._callback(self.token) + finally: + self.finished = True + + def _headers(self, **kwargs): + """ Returns dict containing base headers for all requests for pin login. """ + headers = BASE_HEADERS.copy() + if self.headers: + headers.update(self.headers) + headers.update(kwargs) + return headers + + def _query(self, url, method=None, headers=None, **kwargs): + method = method or self._session.get + log.debug('%s %s', method.__name__.upper(), url) + headers = headers or self._headers() + response = method(url, headers=headers, timeout=self._requestTimeout, **kwargs) + if not response.ok: # pragma: no cover + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + data = response.text.encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None + + +def _connect(cls, url, token, timeout, results, i, job_is_done_event=None): + """ Connects to the specified cls with url and token. Stores the connection + information to results[i] in a threadsafe way. + + Arguments: + cls: the class which is responsible for establishing connection, basically it's + :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer` + url (str): url which should be passed as `baseurl` argument to cls.__init__() + token (str): authentication token which should be passed as `baseurl` argument to cls.__init__() + timeout (int): timeout which should be passed as `baseurl` argument to cls.__init__() + results (list): pre-filled list for results + i (int): index of current job, should be less than len(results) + job_is_done_event (:class:`~threading.Event`): is X_PLEX_ENABLE_FAST_CONNECT is True then the + event would be set as soon the connection is established + """ + starttime = time.time() + try: + device = cls(baseurl=url, token=token, timeout=timeout) + runtime = int(time.time() - starttime) + results[i] = (url, token, device, runtime) + if X_PLEX_ENABLE_FAST_CONNECT and job_is_done_event: + job_is_done_event.set() + except Exception as err: + runtime = int(time.time() - starttime) + log.error('%s: %s', url, err) + results[i] = (url, token, None, runtime) + + +def _chooseConnection(ctype, name, results): + """ Chooses the first (best) connection from the given _connect results. """ + # At this point we have a list of result tuples containing (url, token, PlexServer, runtime) + # or (url, token, None, runtime) in the case a connection could not be established. + for url, token, result, runtime in results: + okerr = 'OK' if result else 'ERR' + log.debug('%s connection %s (%ss): %s?X-Plex-Token=%s', ctype, okerr, runtime, url, token) + results = [r[2] for r in results if r and r[2] is not None] + if results: + log.debug('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token) + return results[0] + raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name)) + + +class AccountOptOut(PlexObject): + """ Represents a single AccountOptOut + 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' + + Attributes: + TAG (str): optOut + key (str): Online Media Source key + value (str): Online Media Source opt_in, opt_out, or opt_out_managed + """ + TAG = 'optOut' + CHOICES = {'opt_in', 'opt_out', 'opt_out_managed'} + + def _loadData(self, data): + self.key = data.attrib.get('key') + self.value = data.attrib.get('value') + + def _updateOptOut(self, option): + """ Sets the Online Media Sources option. + + Parameters: + option (str): see CHOICES + + Raises: + :exc:`~plexapi.exceptions.NotFound`: ``option`` str not found in CHOICES. + """ + if option not in self.CHOICES: + raise NotFound('%s not found in available choices: %s' % (option, self.CHOICES)) + url = self._server.OPTOUTS % {'userUUID': self._server.uuid} + params = {'key': self.key, 'value': option} + self._server.query(url, method=self._server._session.post, params=params) + self.value = option # assume query successful and set the value to option + + def optIn(self): + """ Sets the Online Media Source to "Enabled". """ + self._updateOptOut('opt_in') + + def optOut(self): + """ Sets the Online Media Source to "Disabled". """ + self._updateOptOut('opt_out') + + def optOutManaged(self): + """ Sets the Online Media Source to "Disabled for Managed Users". + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When trying to opt out music. + """ + if self.key == 'tv.plex.provider.music': + raise BadRequest('%s does not have the option to opt out managed users.' % self.key) + self._updateOptOut('opt_out_managed') diff --git a/service.plexskipintro/plexapi/photo.py b/service.plexskipintro/plexapi/photo.py new file mode 100644 index 0000000000..c24d7fb169 --- /dev/null +++ b/service.plexskipintro/plexapi/photo.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +import os +from urllib.parse import quote_plus + +from plexapi import media, utils, video +from plexapi.base import Playable, PlexPartialObject +from plexapi.exceptions import BadRequest +from plexapi.mixins import ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, RatingMixin, TagMixin + + +@utils.registerPlexObject +class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin): + """ Represents a single Photoalbum (collection of photos). + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'photo' + addedAt (datetime): Datetime the photo album was added to the library. + art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>). + composite (str): URL to composite image (/library/metadata/<ratingKey>/composite/<compositeid>) + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the photo album (local://229674). + index (sting): Plex index number for the photo album. + key (str): API URL (/library/metadata/<ratingkey>). + lastRatedAt (datetime): Datetime the photo album was last rated. + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + listType (str): Hardcoded as 'photo' (useful for search filters). + ratingKey (int): Unique key identifying the photo album. + summary (str): Summary of the photoalbum. + thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>). + title (str): Name of the photo album. (Trip to Disney World) + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'photo' + updatedAt (datatime): Datetime the photo album was updated. + userRating (float): Rating of the photo album (0.0 - 10.0) equaling (0 stars - 5 stars). + """ + TAG = 'Directory' + TYPE = 'photo' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.art = data.attrib.get('art') + self.composite = data.attrib.get('composite') + self.fields = self.findItems(data, media.Field) + self.guid = data.attrib.get('guid') + self.index = utils.cast(int, data.attrib.get('index')) + self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 + self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.listType = 'photo' + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.summary = data.attrib.get('summary') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort', self.title) + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.userRating = utils.cast(float, data.attrib.get('userRating')) + + def album(self, title): + """ Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. + + Parameters: + title (str): Title of the photo album to return. + """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItem(key, Photoalbum, title__iexact=title) + + def albums(self, **kwargs): + """ Returns a list of :class:`~plexapi.photo.Photoalbum` objects in the album. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, Photoalbum, **kwargs) + + def photo(self, title): + """ Returns the :class:`~plexapi.photo.Photo` that matches the specified title. + + Parameters: + title (str): Title of the photo to return. + """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItem(key, Photo, title__iexact=title) + + def photos(self, **kwargs): + """ Returns a list of :class:`~plexapi.photo.Photo` objects in the album. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, Photo, **kwargs) + + def clip(self, title): + """ Returns the :class:`~plexapi.video.Clip` that matches the specified title. + + Parameters: + title (str): Title of the clip to return. + """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItem(key, video.Clip, title__iexact=title) + + def clips(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Clip` objects in the album. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, video.Clip, **kwargs) + + def get(self, title): + """ Alias to :func:`~plexapi.photo.Photoalbum.photo`. """ + return self.episode(title) + + def download(self, savepath=None, keep_original_name=False, subfolders=False): + """ Download all photos and clips from the photo ablum. See :func:`~plexapi.base.Playable.download` for details. + + Parameters: + savepath (str): Defaults to current working dir. + keep_original_name (bool): True to keep the original filename otherwise + a friendlier filename is generated. + subfolders (bool): True to separate photos/clips in to photo album folders. + """ + filepaths = [] + for album in self.albums(): + _savepath = os.path.join(savepath, album.title) if subfolders else savepath + filepaths += album.download(_savepath, keep_original_name) + for photo in self.photos() + self.clips(): + filepaths += photo.download(savepath, keep_original_name) + return filepaths + + def _getWebURL(self, base=None): + """ Get the Plex Web URL with the correct parameters. """ + return self._server._buildWebURL(base=base, endpoint='details', key=self.key, legacy=1) + + +@utils.registerPlexObject +class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, TagMixin): + """ Represents a single Photo. + + Attributes: + TAG (str): 'Photo' + TYPE (str): 'photo' + addedAt (datetime): Datetime the photo was added to the library. + createdAtAccuracy (str): Unknown (local). + createdAtTZOffset (int): Unknown (-25200). + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the photo (com.plexapp.agents.none://231714?lang=xn). + index (sting): Plex index number for the photo. + key (str): API URL (/library/metadata/<ratingkey>). + lastRatedAt (datetime): Datetime the photo was last rated. + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + listType (str): Hardcoded as 'photo' (useful for search filters). + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the photo was added to Plex. + parentGuid (str): Plex GUID for the photo album (local://229674). + parentIndex (int): Plex index number for the photo album. + parentKey (str): API URL of the photo album (/library/metadata/<parentRatingKey>). + parentRatingKey (int): Unique key identifying the photo album. + parentThumb (str): URL to photo album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>). + parentTitle (str): Name of the photo album for the photo. + ratingKey (int): Unique key identifying the photo. + summary (str): Summary of the photo. + tags (List<:class:`~plexapi.media.Tag`>): List of tag objects. + thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>). + title (str): Name of the photo. + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'photo' + updatedAt (datatime): Datetime the photo was updated. + userRating (float): Rating of the photo (0.0 - 10.0) equaling (0 stars - 5 stars). + year (int): Year the photo was taken. + """ + TAG = 'Photo' + TYPE = 'photo' + METADATA_TYPE = 'photo' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Playable._loadData(self, data) + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.createdAtAccuracy = data.attrib.get('createdAtAccuracy') + self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset')) + self.fields = self.findItems(data, media.Field) + self.guid = data.attrib.get('guid') + self.index = utils.cast(int, data.attrib.get('index')) + self.key = data.attrib.get('key', '') + self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.listType = 'photo' + self.media = self.findItems(data, media.Media) + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.parentGuid = data.attrib.get('parentGuid') + self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) + self.parentKey = data.attrib.get('parentKey') + self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentThumb = data.attrib.get('parentThumb') + self.parentTitle = data.attrib.get('parentTitle') + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.summary = data.attrib.get('summary') + self.tags = self.findItems(data, media.Tag) + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort', self.title) + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.userRating = utils.cast(float, data.attrib.get('userRating')) + self.year = utils.cast(int, data.attrib.get('year')) + + def _prettyfilename(self): + """ Returns a filename for use in download. """ + if self.parentTitle: + return '%s - %s' % (self.parentTitle, self.title) + return self.title + + def photoalbum(self): + """ Return the photo's :class:`~plexapi.photo.Photoalbum`. """ + return self.fetchItem(self.parentKey) + + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` the item belongs to. """ + if hasattr(self, 'librarySectionID'): + return self._server.library.sectionByID(self.librarySectionID) + elif self.parentKey: + return self._server.library.sectionByID(self.photoalbum().librarySectionID) + else: + raise BadRequest('Unable to get section for photo, can`t find librarySectionID') + + @property + def locations(self): + """ This does not exist in plex xml response but is added to have a common + interface to get the locations of the photo. + + Returns: + List<str> of file paths where the photo is found on disk. + """ + return [part.file for item in self.media for part in item.parts if part] + + def sync(self, resolution, client=None, clientId=None, limit=None, title=None): + """ Add current photo as sync item for specified device. + See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. + + Parameters: + resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the + module :mod:`~plexapi.sync`. + client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`~plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`. + limit (int): maximum count of items to sync, unlimited if `None`. + title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current photo. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + """ + + from plexapi.sync import SyncItem, Policy, MediaSettings + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self.title + sync_item.rootTitle = self.title + sync_item.contentType = self.listType + sync_item.metadataType = self.METADATA_TYPE + sync_item.machineIdentifier = self._server.machineIdentifier + + section = self.section() + + sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key)) + sync_item.policy = Policy.create(limit) + sync_item.mediaSettings = MediaSettings.createPhoto(resolution) + + return myplex.sync(sync_item, client=client, clientId=clientId) + + def _getWebURL(self, base=None): + """ Get the Plex Web URL with the correct parameters. """ + return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1) diff --git a/service.plexskipintro/plexapi/playlist.py b/service.plexskipintro/plexapi/playlist.py new file mode 100644 index 0000000000..88ab6aadf5 --- /dev/null +++ b/service.plexskipintro/plexapi/playlist.py @@ -0,0 +1,470 @@ +# -*- coding: utf-8 -*- +import re +from urllib.parse import quote_plus, unquote + +from plexapi import media, utils +from plexapi.base import Playable, PlexPartialObject +from plexapi.exceptions import BadRequest, NotFound, Unsupported +from plexapi.library import LibrarySection +from plexapi.mixins import ArtMixin, PosterMixin, SmartFilterMixin +from plexapi.playqueue import PlayQueue +from plexapi.utils import deprecated + + +@utils.registerPlexObject +class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMixin): + """ Represents a single Playlist. + + Attributes: + TAG (str): 'Playlist' + TYPE (str): 'playlist' + addedAt (datetime): Datetime the playlist was added to the server. + allowSync (bool): True if you allow syncing playlists. + composite (str): URL to composite image (/playlist/<ratingKey>/composite/<compositeid>) + content (str): The filter URI string for smart playlists. + duration (int): Duration of the playlist in milliseconds. + durationInSeconds (int): Duration of the playlist in seconds. + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the playlist (com.plexapp.agents.none://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX). + icon (str): Icon URI string for smart playlists. + key (str): API URL (/playlist/<ratingkey>). + leafCount (int): Number of items in the playlist view. + playlistType (str): 'audio', 'video', or 'photo' + ratingKey (int): Unique key identifying the playlist. + smart (bool): True if the playlist is a smart playlist. + summary (str): Summary of the playlist. + title (str): Name of the playlist. + type (str): 'playlist' + updatedAt (datatime): Datetime the playlist was updated. + """ + TAG = 'Playlist' + TYPE = 'playlist' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Playable._loadData(self, data) + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.composite = data.attrib.get('composite') # url to thumbnail + self.content = data.attrib.get('content') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.durationInSeconds = utils.cast(int, data.attrib.get('durationInSeconds')) + self.fields = self.findItems(data, media.Field) + self.guid = data.attrib.get('guid') + self.icon = data.attrib.get('icon') + self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50 + self.leafCount = utils.cast(int, data.attrib.get('leafCount')) + self.playlistType = data.attrib.get('playlistType') + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.smart = utils.cast(bool, data.attrib.get('smart')) + self.summary = data.attrib.get('summary') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self._items = None # cache for self.items + self._section = None # cache for self.section + self._filters = None # cache for self.filters + + def __len__(self): # pragma: no cover + return len(list(self.items())) + + def __iter__(self): # pragma: no cover + for item in list(self.items()): + yield item + + def __contains__(self, other): # pragma: no cover + return any(i.key == other.key for i in list(self.items())) + + def __getitem__(self, key): # pragma: no cover + return list(self.items())[key] + + @property + def thumb(self): + """ Alias to self.composite. """ + return self.composite + + @property + def metadataType(self): + """ Returns the type of metadata in the playlist (movie, track, or photo). """ + if self.isVideo: + return 'movie' + elif self.isAudio: + return 'track' + elif self.isPhoto: + return 'photo' + else: + raise Unsupported('Unexpected playlist type') + + @property + def isVideo(self): + """ Returns True if this is a video playlist. """ + return self.playlistType == 'video' + + @property + def isAudio(self): + """ Returns True if this is an audio playlist. """ + return self.playlistType == 'audio' + + @property + def isPhoto(self): + """ Returns True if this is a photo playlist. """ + return self.playlistType == 'photo' + + def _getPlaylistItemID(self, item): + """ Match an item to a playlist item and return the item playlistItemID. """ + for _item in list(self.items()): + if _item.ratingKey == item.ratingKey: + return _item.playlistItemID + raise NotFound('Item with title "%s" not found in the playlist' % item.title) + + def filters(self): + """ Returns the search filter dict for smart playlist. + The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search` + to get the list of items. + """ + if self.smart and self._filters is None: + self._filters = self._parseFilters(self.content) + return self._filters + + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to get the section for a regular playlist. + :class:`plexapi.exceptions.Unsupported`: When unable to determine the library section. + """ + if not self.smart: + raise BadRequest('Regular playlists are not associated with a library.') + + if self._section is None: + # Try to parse the library section from the content URI string + match = re.search(r'/library/sections/(\d+)/all', unquote(self.content or '')) + if match: + sectionKey = int(match.group(1)) + self._section = self._server.library.sectionByID(sectionKey) + return self._section + + # Try to get the library section from the first item in the playlist + if list(self.items()): + self._section = list(self.items())[0].section() + return self._section + + raise Unsupported('Unable to determine the library section') + + return self._section + + def item(self, title): + """ Returns the item in the playlist that matches the specified title. + + Parameters: + title (str): Title of the item to return. + + Raises: + :class:`plexapi.exceptions.NotFound`: When the item is not found in the playlist. + """ + for item in list(self.items()): + if item.title.lower() == title.lower(): + return item + raise NotFound('Item with title "%s" not found in the playlist' % title) + + def items(self): + """ Returns a list of all items in the playlist. """ + if self._items is None: + key = '%s/items' % self.key + items = self.fetchItems(key) + self._items = items + return self._items + + def get(self, title): + """ Alias to :func:`~plexapi.playlist.Playlist.item`. """ + return self.item(title) + + def addItems(self, items): + """ Add items to the playlist. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be added to the playlist. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to add items to a smart playlist. + """ + if self.smart: + raise BadRequest('Cannot add items to a smart playlist.') + + if items and not isinstance(items, (list, tuple)): + items = [items] + + ratingKeys = [] + for item in items: + if item.listType != self.playlistType: # pragma: no cover + raise BadRequest('Can not mix media types when building a playlist: %s and %s' % + (self.playlistType, item.listType)) + ratingKeys.append(str(item.ratingKey)) + + ratingKeys = ','.join(ratingKeys) + uri = '%s/library/metadata/%s' % (self._server._uriRoot(), ratingKeys) + + key = '%s/items%s' % (self.key, utils.joinArgs({ + 'uri': uri + })) + self._server.query(key, method=self._server._session.put) + + @deprecated('use "removeItems" instead', stacklevel=3) + def removeItem(self, item): + self.removeItems(item) + + def removeItems(self, items): + """ Remove items from the playlist. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be removed from the playlist. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to remove items from a smart playlist. + :class:`plexapi.exceptions.NotFound`: When the item does not exist in the playlist. + """ + if self.smart: + raise BadRequest('Cannot remove items from a smart playlist.') + + if items and not isinstance(items, (list, tuple)): + items = [items] + + for item in items: + playlistItemID = self._getPlaylistItemID(item) + key = '%s/items/%s' % (self.key, playlistItemID) + self._server.query(key, method=self._server._session.delete) + + def moveItem(self, item, after=None): + """ Move an item to a new position in the playlist. + + Parameters: + items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be moved in the playlist. + after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to move the item after in the playlist. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart playlist. + :class:`plexapi.exceptions.NotFound`: When the item or item after does not exist in the playlist. + """ + if self.smart: + raise BadRequest('Cannot move items in a smart playlist.') + + playlistItemID = self._getPlaylistItemID(item) + key = '%s/items/%s/move' % (self.key, playlistItemID) + + if after: + afterPlaylistItemID = self._getPlaylistItemID(after) + key += '?after=%s' % afterPlaylistItemID + + self._server.query(key, method=self._server._session.put) + + def updateFilters(self, limit=None, sort=None, filters=None, **kwargs): + """ Update the filters for a smart playlist. + + Parameters: + limit (int): Limit the number of items in the playlist. + sort (str or list, optional): A string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): A dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Additional custom filters to apply to the search results. + See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying update filters for a regular playlist. + """ + if not self.smart: + raise BadRequest('Cannot update filters for a regular playlist.') + + section = self.section() + searchKey = section._buildSearchKey( + sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs) + uri = '%s%s' % (self._server._uriRoot(), searchKey) + + key = '%s/items%s' % (self.key, utils.joinArgs({ + 'uri': uri + })) + self._server.query(key, method=self._server._session.put) + + def _edit(self, **kwargs): + """ Actually edit the playlist. """ + key = '%s%s' % (self.key, utils.joinArgs(kwargs)) + self._server.query(key, method=self._server._session.put) + + def edit(self, title=None, summary=None): + """ Edit the playlist. + + Parameters: + title (str, optional): The title of the playlist. + summary (str, optional): The summary of the playlist. + """ + args = {} + if title: + args['title'] = title + if summary: + args['summary'] = summary + self._edit(**args) + + def delete(self): + """ Delete the playlist. """ + self._server.query(self.key, method=self._server._session.delete) + + def playQueue(self, *args, **kwargs): + """ Returns a new :class:`~plexapi.playqueue.PlayQueue` from the playlist. """ + return PlayQueue.create(self._server, self, *args, **kwargs) + + @classmethod + def _create(cls, server, title, items): + """ Create a regular playlist. """ + if not items: + raise BadRequest('Must include items to add when creating new playlist.') + + if items and not isinstance(items, (list, tuple)): + items = [items] + + listType = items[0].listType + ratingKeys = [] + for item in items: + if item.listType != listType: # pragma: no cover + raise BadRequest('Can not mix media types when building a playlist.') + ratingKeys.append(str(item.ratingKey)) + + ratingKeys = ','.join(ratingKeys) + uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys) + + key = '/playlists%s' % utils.joinArgs({ + 'uri': uri, + 'type': listType, + 'title': title, + 'smart': 0 + }) + data = server.query(key, method=server._session.post)[0] + return cls(server, data, initpath=key) + + @classmethod + def _createSmart(cls, server, title, section, limit=None, libtype=None, sort=None, filters=None, **kwargs): + """ Create a smart playlist. """ + if not isinstance(section, LibrarySection): + section = server.library.section(section) + + libtype = libtype or section.METADATA_TYPE + + searchKey = section._buildSearchKey( + sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) + uri = '%s%s' % (server._uriRoot(), searchKey) + + key = '/playlists%s' % utils.joinArgs({ + 'uri': uri, + 'type': section.CONTENT_TYPE, + 'title': title, + 'smart': 1, + }) + data = server.query(key, method=server._session.post)[0] + return cls(server, data, initpath=key) + + @classmethod + def create(cls, server, title, section=None, items=None, smart=False, limit=None, + libtype=None, sort=None, filters=None, **kwargs): + """ Create a playlist. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): Server to create the playlist on. + title (str): Title of the playlist. + section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists only, + the library section to create the playlist in. + items (List): Regular playlists only, list of :class:`~plexapi.audio.Audio`, + :class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the playlist. + smart (bool): True to create a smart playlist. Default False. + limit (int): Smart playlists only, limit the number of items in the playlist. + libtype (str): Smart playlists only, the specific type of content to filter + (movie, show, season, episode, artist, album, track, photoalbum, photo). + sort (str or list, optional): Smart playlists only, a string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): Smart playlists only, a dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Smart playlists only, additional custom filters to apply to the + search results. See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When no items are included to create the playlist. + :class:`plexapi.exceptions.BadRequest`: When mixing media types in the playlist. + + Returns: + :class:`~plexapi.playlist.Playlist`: A new instance of the created Playlist. + """ + if smart: + return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs) + else: + return cls._create(server, title, items) + + def copyToUser(self, user): + """ Copy playlist to another user account. + + Parameters: + user (str): Username, email or user id of the user to copy the playlist to. + """ + userServer = self._server.switchUser(user) + return self.create(server=userServer, title=self.title, items=list(self.items())) + + def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None, + unwatched=False, title=None): + """ Add the playlist as a sync item for the specified device. + See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`~plexapi.sync` module. Used only when playlist contains video. + photoResolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in + the module :mod:`~plexapi.sync`. Used only when playlist contains photos. + audioBitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values + from the module :mod:`~plexapi.sync`. Used only when playlist contains audio. + client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`~plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`. + limit (int): maximum count of items to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current photo. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When playlist is not allowed to sync. + :exc:`~plexapi.exceptions.Unsupported`: When playlist content is unsupported. + + Returns: + :class:`~plexapi.sync.SyncItem`: A new instance of the created sync item. + """ + if not self.allowSync: + raise BadRequest('The playlist is not allowed to sync') + + from plexapi.sync import SyncItem, Policy, MediaSettings + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self.title + sync_item.rootTitle = self.title + sync_item.contentType = self.playlistType + sync_item.metadataType = self.metadataType + sync_item.machineIdentifier = self._server.machineIdentifier + + sync_item.location = 'playlist:///%s' % quote_plus(self.guid) + sync_item.policy = Policy.create(limit, unwatched) + + if self.isVideo: + sync_item.mediaSettings = MediaSettings.createVideo(videoQuality) + elif self.isAudio: + sync_item.mediaSettings = MediaSettings.createMusic(audioBitrate) + elif self.isPhoto: + sync_item.mediaSettings = MediaSettings.createPhoto(photoResolution) + else: + raise Unsupported('Unsupported playlist content') + + return myplex.sync(sync_item, client=client, clientId=clientId) + + def _getWebURL(self, base=None): + """ Get the Plex Web URL with the correct parameters. """ + return self._server._buildWebURL(base=base, endpoint='playlist', key=self.key) diff --git a/service.plexskipintro/plexapi/playqueue.py b/service.plexskipintro/plexapi/playqueue.py new file mode 100644 index 0000000000..ca6fda6393 --- /dev/null +++ b/service.plexskipintro/plexapi/playqueue.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +from urllib.parse import quote_plus + +from plexapi import utils +from plexapi.base import PlexObject +from plexapi.exceptions import BadRequest, Unsupported + + +class PlayQueue(PlexObject): + """Control a PlayQueue. + + Attributes: + TAG (str): 'PlayQueue' + TYPE (str): 'playqueue' + identifier (str): com.plexapp.plugins.library + items (list): List of :class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist` + mediaTagPrefix (str): Fx /system/bundle/media/flags/ + mediaTagVersion (int): Fx 1485957738 + playQueueID (int): ID of the PlayQueue. + playQueueLastAddedItemID (int): + Defines where the "Up Next" region starts. Empty unless PlayQueue is modified after creation. + playQueueSelectedItemID (int): The queue item ID of the currently selected item. + playQueueSelectedItemOffset (int): + The offset of the selected item in the PlayQueue, from the beginning of the queue. + playQueueSelectedMetadataItemID (int): ID of the currently selected item, matches ratingKey. + playQueueShuffled (bool): True if shuffled. + playQueueSourceURI (str): Original URI used to create the PlayQueue. + playQueueTotalCount (int): How many items in the PlayQueue. + playQueueVersion (int): Version of the PlayQueue. Increments every time a change is made to the PlayQueue. + selectedItem (:class:`~plexapi.media.Media`): Media object for the currently selected item. + _server (:class:`~plexapi.server.PlexServer`): PlexServer associated with the PlayQueue. + size (int): Alias for playQueueTotalCount. + """ + + TAG = "PlayQueue" + TYPE = "playqueue" + + def _loadData(self, data): + self._data = data + self.identifier = data.attrib.get("identifier") + self.mediaTagPrefix = data.attrib.get("mediaTagPrefix") + self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion")) + self.playQueueID = utils.cast(int, data.attrib.get("playQueueID")) + self.playQueueLastAddedItemID = utils.cast( + int, data.attrib.get("playQueueLastAddedItemID") + ) + self.playQueueSelectedItemID = utils.cast( + int, data.attrib.get("playQueueSelectedItemID") + ) + self.playQueueSelectedItemOffset = utils.cast( + int, data.attrib.get("playQueueSelectedItemOffset") + ) + self.playQueueSelectedMetadataItemID = utils.cast( + int, data.attrib.get("playQueueSelectedMetadataItemID") + ) + self.playQueueShuffled = utils.cast( + bool, data.attrib.get("playQueueShuffled", 0) + ) + self.playQueueSourceURI = data.attrib.get("playQueueSourceURI") + self.playQueueTotalCount = utils.cast( + int, data.attrib.get("playQueueTotalCount") + ) + self.playQueueVersion = utils.cast(int, data.attrib.get("playQueueVersion")) + self.size = utils.cast(int, data.attrib.get("size", 0)) + self.items = self.findItems(data) + self.selectedItem = self[self.playQueueSelectedItemOffset] + + def __getitem__(self, key): + if not self.items: + return None + return self.items[key] + + def __len__(self): + return self.playQueueTotalCount + + def __iter__(self): + yield from self.items + + def __contains__(self, media): + """Returns True if the PlayQueue contains the provided media item.""" + return any(x.playQueueItemID == media.playQueueItemID for x in self.items) + + def getQueueItem(self, item): + """ + Accepts a media item and returns a similar object from this PlayQueue. + Useful for looking up playQueueItemIDs using items obtained from the Library. + """ + matches = [x for x in self.items if x == item] + if len(matches) == 1: + return matches[0] + elif len(matches) > 1: + raise BadRequest( + "{item} occurs multiple times in this PlayQueue, provide exact item".format(item=item) + ) + else: + raise BadRequest("{item} not valid for this PlayQueue".format(item=item)) + + @classmethod + def get( + cls, + server, + playQueueID, + own=False, + center=None, + window=50, + includeBefore=True, + includeAfter=True, + ): + """Retrieve an existing :class:`~plexapi.playqueue.PlayQueue` by identifier. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): Server you are connected to. + playQueueID (int): Identifier of an existing PlayQueue. + own (bool, optional): If server should transfer ownership. + center (int, optional): The playQueueItemID of the center of the window. Does not change selectedItem. + window (int, optional): Number of items to return from each side of the center item. + includeBefore (bool, optional): + Include items before the center, defaults True. Does not include center if False. + includeAfter (bool, optional): + Include items after the center, defaults True. Does not include center if False. + """ + args = { + "own": utils.cast(int, own), + "window": window, + "includeBefore": utils.cast(int, includeBefore), + "includeAfter": utils.cast(int, includeAfter), + } + if center: + args["center"] = center + + path = "/playQueues/{playQueueID}{args}".format(playQueueID=playQueueID, args=utils.joinArgs(args)) + data = server.query(path, method=server._session.get) + c = cls(server, data, initpath=path) + c._server = server + return c + + @classmethod + def create( + cls, + server, + items, + startItem=None, + shuffle=0, + repeat=0, + includeChapters=1, + includeRelated=1, + continuous=0, + ): + """Create and return a new :class:`~plexapi.playqueue.PlayQueue`. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): Server you are connected to. + items (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): + A media item, list of media items, or Playlist. + startItem (:class:`~plexapi.media.Media`, optional): + Media item in the PlayQueue where playback should begin. + shuffle (int, optional): Start the playqueue shuffled. + repeat (int, optional): Start the playqueue shuffled. + includeChapters (int, optional): include Chapters. + includeRelated (int, optional): include Related. + continuous (int, optional): include additional items after the initial item. + For a show this would be the next episodes, for a movie it does nothing. + """ + args = { + "includeChapters": includeChapters, + "includeRelated": includeRelated, + "repeat": repeat, + "shuffle": shuffle, + "continuous": continuous, + } + + if isinstance(items, list): + item_keys = ",".join([str(x.ratingKey) for x in items]) + uri_args = quote_plus("/library/metadata/{item_keys}".format(item_keys=item_keys)) + args["uri"] = "library:///directory/{uri_args}".format(uri_args=uri_args) + args["type"] = items[0].listType + elif items.type == "playlist": + args["playlistID"] = items.ratingKey + args["type"] = items.playlistType + else: + uuid = items.section().uuid + args["type"] = items.listType + args["uri"] = "library://{uuid}/item/{key}".format(uuid=uuid, key=items.key) + + if startItem: + args["key"] = startItem.key + + path = "/playQueues{args}".format(args=utils.joinArgs(args)) + data = server.query(path, method=server._session.post) + c = cls(server, data, initpath=path) + c.playQueueType = args["type"] + c._server = server + return c + + def addItem(self, item, playNext=False, refresh=True): + """ + Append the provided item to the "Up Next" section of the PlayQueue. + Items can only be added to the section immediately following the current playing item. + + Parameters: + item (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): Single media item or Playlist. + playNext (bool, optional): If True, add this item to the front of the "Up Next" section. + If False, the item will be appended to the end of the "Up Next" section. + Only has an effect if an item has already been added to the "Up Next" section. + See https://support.plex.tv/articles/202188298-play-queues/ for more details. + refresh (bool, optional): Refresh the PlayQueue from the server before updating. + """ + if refresh: + self.refresh() + + args = {} + if item.type == "playlist": + args["playlistID"] = item.ratingKey + itemType = item.playlistType + else: + uuid = item.section().uuid + itemType = item.listType + args["uri"] = "library://{uuid}/item{key}".format(uuid=uuid, key=item.key) + + if itemType != self.playQueueType: + raise Unsupported("Item type does not match PlayQueue type") + + if playNext: + args["next"] = 1 + + path = "/playQueues/{playQueueID}{args}".format(playQueueID=self.playQueueID, args=utils.joinArgs(args)) + data = self._server.query(path, method=self._server._session.put) + self._loadData(data) + + def moveItem(self, item, after=None, refresh=True): + """ + Moves an item to the beginning of the PlayQueue. If `after` is provided, + the item will be placed immediately after the specified item. + + Parameters: + item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move. + afterItemID (:class:`~plexapi.base.Playable`, optional): A different item in the PlayQueue. + If provided, `item` will be placed in the PlayQueue after this item. + refresh (bool, optional): Refresh the PlayQueue from the server before updating. + """ + args = {} + + if refresh: + self.refresh() + + if item not in self: + item = self.getQueueItem(item) + + if after: + if after not in self: + after = self.getQueueItem(after) + args["after"] = after.playQueueItemID + + path = "/playQueues/{playQueueID}/items/{playQueueItemID}/move{args}".format( + playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID, args=utils.joinArgs(args) + ) + data = self._server.query(path, method=self._server._session.put) + self._loadData(data) + + def removeItem(self, item, refresh=True): + """Remove an item from the PlayQueue. + + Parameters: + item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move. + refresh (bool, optional): Refresh the PlayQueue from the server before updating. + """ + if refresh: + self.refresh() + + if item not in self: + item = self.getQueueItem(item) + + path = "/playQueues/{playQueueID}/items/{playQueueItemID}".format( + playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID + ) + data = self._server.query(path, method=self._server._session.delete) + self._loadData(data) + + def clear(self): + """Remove all items from the PlayQueue.""" + path = "/playQueues/{playQueueID}/items".format(playQueueID=self.playQueueID) + data = self._server.query(path, method=self._server._session.delete) + self._loadData(data) + + def refresh(self): + """Refresh the PlayQueue from the Plex server.""" + path = "/playQueues/{playQueueID}".format(playQueueID=self.playQueueID) + data = self._server.query(path, method=self._server._session.get) + self._loadData(data) diff --git a/service.plexskipintro/plexapi/server.py b/service.plexskipintro/plexapi/server.py new file mode 100644 index 0000000000..6cf32102a3 --- /dev/null +++ b/service.plexskipintro/plexapi/server.py @@ -0,0 +1,1155 @@ +# -*- coding: utf-8 -*- +from urllib.parse import urlencode +from xml.etree import ElementTree + +import requests +from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log, + logfilter) +from plexapi import utils +from plexapi.alert import AlertListener +from plexapi.base import PlexObject +from plexapi.client import PlexClient +from plexapi.collection import Collection +from plexapi.exceptions import BadRequest, NotFound, Unauthorized +from plexapi.library import Hub, Library, Path, File +from plexapi.media import Conversion, Optimized +from plexapi.playlist import Playlist +from plexapi.playqueue import PlayQueue +from plexapi.settings import Settings +from plexapi.utils import deprecated +from requests.status_codes import _codes as codes + +# Need these imports to populate utils.PLEXOBJECTS +from plexapi import audio as _audio # noqa: F401 +from plexapi import collection as _collection # noqa: F401 +from plexapi import media as _media # noqa: F401 +from plexapi import photo as _photo # noqa: F401 +from plexapi import playlist as _playlist # noqa: F401 +from plexapi import video as _video # noqa: F401 + + +class PlexServer(PlexObject): + """ This is the main entry point to interacting with a Plex server. It allows you to + list connected clients, browse your library sections and perform actions such as + emptying trash. If you do not know the auth token required to access your Plex + server, or simply want to access your server with your username and password, you + can also create an PlexServer instance from :class:`~plexapi.myplex.MyPlexAccount`. + + Parameters: + baseurl (str): Base url for to access the Plex Media Server (default: 'http://localhost:32400'). + token (str): Required Plex authentication token to access the server. + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from the server. + timeout (int, optional): Timeout in seconds on initial connection to the server + (default config.TIMEOUT). + + Attributes: + allowCameraUpload (bool): True if server allows camera upload. + allowChannelAccess (bool): True if server allows channel access (iTunes?). + allowMediaDeletion (bool): True is server allows media to be deleted. + allowSharing (bool): True is server allows sharing. + allowSync (bool): True is server allows sync. + backgroundProcessing (bool): Unknown + certificate (bool): True if server has an HTTPS certificate. + companionProxy (bool): Unknown + diagnostics (bool): Unknown + eventStream (bool): Unknown + friendlyName (str): Human friendly name for this server. + hubSearch (bool): True if `Hub Search <https://www.plex.tv/blog + /seek-plex-shall-find-leveling-web-app/>`_ is enabled. I believe this + is enabled for everyone + machineIdentifier (str): Unique ID for this server (looks like an md5). + multiuser (bool): True if `multiusers <https://support.plex.tv/hc/en-us/articles + /200250367-Multi-User-Support>`_ are enabled. + myPlex (bool): Unknown (True if logged into myPlex?). + myPlexMappingState (str): Unknown (ex: mapped). + myPlexSigninState (str): Unknown (ex: ok). + myPlexSubscription (bool): True if you have a myPlex subscription. + myPlexUsername (str): Email address if signed into myPlex (user@example.com) + ownerFeatures (list): List of features allowed by the server owner. This may be based + on your PlexPass subscription. Features include: camera_upload, cloudsync, + content_filter, dvr, hardware_transcoding, home, lyrics, music_videos, pass, + photo_autotags, premium_music_metadata, session_bandwidth_restrictions, sync, + trailers, webhooks (and maybe more). + photoAutoTag (bool): True if photo `auto-tagging <https://support.plex.tv/hc/en-us + /articles/234976627-Auto-Tagging-of-Photos>`_ is enabled. + platform (str): Platform the server is hosted on (ex: Linux) + platformVersion (str): Platform version (ex: '6.1 (Build 7601)', '4.4.0-59-generic'). + pluginHost (bool): Unknown + readOnlyLibraries (bool): Unknown + requestParametersInCookie (bool): Unknown + streamingBrainVersion (bool): Current `Streaming Brain <https://www.plex.tv/blog + /mcstreamy-brain-take-world-two-easy-steps/>`_ version. + sync (bool): True if `syncing to a device <https://support.plex.tv/hc/en-us/articles + /201053678-Sync-Media-to-a-Device>`_ is enabled. + transcoderActiveVideoSessions (int): Number of active video transcoding sessions. + transcoderAudio (bool): True if audio transcoding audio is available. + transcoderLyrics (bool): True if audio transcoding lyrics is available. + transcoderPhoto (bool): True if audio transcoding photos is available. + transcoderSubtitles (bool): True if audio transcoding subtitles is available. + transcoderVideo (bool): True if audio transcoding video is available. + transcoderVideoBitrates (bool): List of video bitrates. + transcoderVideoQualities (bool): List of video qualities. + transcoderVideoResolutions (bool): List of video resolutions. + updatedAt (int): Datetime the server was updated. + updater (bool): Unknown + version (str): Current Plex version (ex: 1.3.2.3112-1751929) + voiceSearch (bool): True if voice search is enabled. (is this Google Voice search?) + _baseurl (str): HTTP address of the client. + _token (str): Token used to access this client. + _session (obj): Requests session object used to access this client. + """ + key = '/' + + def __init__(self, baseurl=None, token=None, session=None, timeout=None): + self._baseurl = baseurl or CONFIG.get('auth.server_baseurl', 'http://localhost:32400') + self._baseurl = self._baseurl.rstrip('/') + self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token')) + self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' + self._session = session or requests.Session() + self._timeout = timeout + self._library = None # cached library + self._settings = None # cached settings + self._myPlexAccount = None # cached myPlexAccount + self._systemAccounts = None # cached list of SystemAccount + self._systemDevices = None # cached list of SystemDevice + data = self.query(self.key, timeout=self._timeout) + super(PlexServer, self).__init__(self, data, self.key) + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) + self.allowChannelAccess = utils.cast(bool, data.attrib.get('allowChannelAccess')) + self.allowMediaDeletion = utils.cast(bool, data.attrib.get('allowMediaDeletion')) + self.allowSharing = utils.cast(bool, data.attrib.get('allowSharing')) + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.backgroundProcessing = utils.cast(bool, data.attrib.get('backgroundProcessing')) + self.certificate = utils.cast(bool, data.attrib.get('certificate')) + self.companionProxy = utils.cast(bool, data.attrib.get('companionProxy')) + self.diagnostics = utils.toList(data.attrib.get('diagnostics')) + self.eventStream = utils.cast(bool, data.attrib.get('eventStream')) + self.friendlyName = data.attrib.get('friendlyName') + self.hubSearch = utils.cast(bool, data.attrib.get('hubSearch')) + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.multiuser = utils.cast(bool, data.attrib.get('multiuser')) + self.myPlex = utils.cast(bool, data.attrib.get('myPlex')) + self.myPlexMappingState = data.attrib.get('myPlexMappingState') + self.myPlexSigninState = data.attrib.get('myPlexSigninState') + self.myPlexSubscription = utils.cast(bool, data.attrib.get('myPlexSubscription')) + self.myPlexUsername = data.attrib.get('myPlexUsername') + self.ownerFeatures = utils.toList(data.attrib.get('ownerFeatures')) + self.photoAutoTag = utils.cast(bool, data.attrib.get('photoAutoTag')) + self.platform = data.attrib.get('platform') + self.platformVersion = data.attrib.get('platformVersion') + self.pluginHost = utils.cast(bool, data.attrib.get('pluginHost')) + self.readOnlyLibraries = utils.cast(int, data.attrib.get('readOnlyLibraries')) + self.requestParametersInCookie = utils.cast(bool, data.attrib.get('requestParametersInCookie')) + self.streamingBrainVersion = data.attrib.get('streamingBrainVersion') + self.sync = utils.cast(bool, data.attrib.get('sync')) + self.transcoderActiveVideoSessions = int(data.attrib.get('transcoderActiveVideoSessions', 0)) + self.transcoderAudio = utils.cast(bool, data.attrib.get('transcoderAudio')) + self.transcoderLyrics = utils.cast(bool, data.attrib.get('transcoderLyrics')) + self.transcoderPhoto = utils.cast(bool, data.attrib.get('transcoderPhoto')) + self.transcoderSubtitles = utils.cast(bool, data.attrib.get('transcoderSubtitles')) + self.transcoderVideo = utils.cast(bool, data.attrib.get('transcoderVideo')) + self.transcoderVideoBitrates = utils.toList(data.attrib.get('transcoderVideoBitrates')) + self.transcoderVideoQualities = utils.toList(data.attrib.get('transcoderVideoQualities')) + self.transcoderVideoResolutions = utils.toList(data.attrib.get('transcoderVideoResolutions')) + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.updater = utils.cast(bool, data.attrib.get('updater')) + self.version = data.attrib.get('version') + self.voiceSearch = utils.cast(bool, data.attrib.get('voiceSearch')) + + def _headers(self, **kwargs): + """ Returns dict containing base headers for all requests to the server. """ + headers = BASE_HEADERS.copy() + if self._token: + headers['X-Plex-Token'] = self._token + headers.update(kwargs) + return headers + + def _uriRoot(self): + return 'server://%s/com.plexapp.plugins.library' % self.machineIdentifier + + @property + def library(self): + """ Library to browse or search your media. """ + if not self._library: + try: + data = self.query(Library.key) + self._library = Library(self, data) + except BadRequest: + data = self.query('/library/sections/') + # Only the owner has access to /library + # so just return the library without the data. + return Library(self, data) + return self._library + + @property + def settings(self): + """ Returns a list of all server settings. """ + if not self._settings: + data = self.query(Settings.key) + self._settings = Settings(self, data) + return self._settings + + def account(self): + """ Returns the :class:`~plexapi.server.Account` object this server belongs to. """ + data = self.query(Account.key) + return Account(self, data) + + def claim(self, account): + """ Claim the Plex server using a :class:`~plexapi.myplex.MyPlexAccount`. + This will only work with an unclaimed server on localhost or the same subnet. + + Parameters: + account (:class:`~plexapi.myplex.MyPlexAccount`): The account used to + claim the server. + """ + key = '/myplex/claim' + params = {'token': account.claimToken()} + data = self.query(key, method=self._session.post, params=params) + return Account(self, data) + + def unclaim(self): + """ Unclaim the Plex server. This will remove the server from your + :class:`~plexapi.myplex.MyPlexAccount`. + """ + data = self.query(Account.key, method=self._session.delete) + return Account(self, data) + + @property + def activities(self): + """Returns all current PMS activities.""" + activities = [] + for elem in self.query(Activity.key): + activities.append(Activity(self, elem)) + return activities + + def agents(self, mediaType=None): + """ Returns the :class:`~plexapi.media.Agent` objects this server has available. """ + key = '/system/agents' + if mediaType: + key += '?mediaType=%s' % mediaType + return self.fetchItems(key) + + def createToken(self, type='delegation', scope='all'): + """ Create a temp access token for the server. """ + if not self._token: + # Handle unclaimed servers + return None + q = self.query('/security/token?type=%s&scope=%s' % (type, scope)) + return q.attrib.get('token') + + def switchUser(self, username, session=None, timeout=None): + """ Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username. + Note: Only the admin account can switch to other users. + + Parameters: + username (str): Username, email or user id of the user to log in to the server. + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from the server. This will default to the same + session as the admin account if no new session is provided. + timeout (int, optional): Timeout in seconds on initial connection to the server. + This will default to the same timeout as the admin account if no new timeout + is provided. + + Example: + + .. code-block:: python + + from plexapi.server import PlexServer + # Login to the Plex server using the admin token + plex = PlexServer('http://plexserver:32400', token='2ffLuB84dqLswk9skLos') + # Login to the same Plex server using a different account + userPlex = plex.switchUser("Username") + + """ + user = self.myPlexAccount().user(username) + userToken = user.get_token(self.machineIdentifier) + if session is None: + session = self._session + if timeout is None: + timeout = self._timeout + return PlexServer(self._baseurl, token=userToken, session=session, timeout=timeout) + + def systemAccounts(self): + """ Returns a list of :class:`~plexapi.server.SystemAccount` objects this server contains. """ + if self._systemAccounts is None: + key = '/accounts' + self._systemAccounts = self.fetchItems(key, SystemAccount) + return self._systemAccounts + + def systemAccount(self, accountID): + """ Returns the :class:`~plexapi.server.SystemAccount` object for the specified account ID. + + Parameters: + accountID (int): The :class:`~plexapi.server.SystemAccount` ID. + """ + try: + return next(account for account in self.systemAccounts() if account.id == accountID) + except StopIteration: + raise NotFound('Unknown account with accountID=%s' % accountID) from None + + def systemDevices(self): + """ Returns a list of :class:`~plexapi.server.SystemDevice` objects this server contains. """ + if self._systemDevices is None: + key = '/devices' + self._systemDevices = self.fetchItems(key, SystemDevice) + return self._systemDevices + + def systemDevice(self, deviceID): + """ Returns the :class:`~plexapi.server.SystemDevice` object for the specified device ID. + + Parameters: + deviceID (int): The :class:`~plexapi.server.SystemDevice` ID. + """ + try: + return next(device for device in self.systemDevices() if device.id == deviceID) + except StopIteration: + raise NotFound('Unknown device with deviceID=%s' % deviceID) from None + + def myPlexAccount(self): + """ Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same + token to access this server. If you are not the owner of this PlexServer + you're likley to recieve an authentication error calling this. + """ + if self._myPlexAccount is None: + from plexapi.myplex import MyPlexAccount + self._myPlexAccount = MyPlexAccount(token=self._token) + return self._myPlexAccount + + def _myPlexClientPorts(self): + """ Sometimes the PlexServer does not properly advertise port numbers required + to connect. This attemps to look up device port number from plex.tv. + See issue #126: Make PlexServer.clients() more user friendly. + https://github.com/pkkid/python-plexapi/issues/126 + """ + try: + ports = {} + account = self.myPlexAccount() + for device in account.devices(): + if device.connections and ':' in device.connections[0][6:]: + ports[device.clientIdentifier] = device.connections[0].split(':')[-1] + return ports + except Exception as err: + log.warning('Unable to fetch client ports from myPlex: %s', err) + return ports + + def browse(self, path=None, includeFiles=True): + """ Browse the system file path using the Plex API. + Returns list of :class:`~plexapi.library.Path` and :class:`~plexapi.library.File` objects. + + Parameters: + path (:class:`~plexapi.library.Path` or str, optional): Full path to browse. + includeFiles (bool): True to include files when browsing (Default). + False to only return folders. + """ + if isinstance(path, Path): + key = path.key + elif path is not None: + base64path = utils.base64str(path) + key = '/services/browse/%s' % base64path + else: + key = '/services/browse' + if includeFiles: + key += '?includeFiles=1' + return self.fetchItems(key) + + def walk(self, path=None): + """ Walk the system file tree using the Plex API similar to `os.walk`. + Yields a 3-tuple `(path, paths, files)` where + `path` is a string of the directory path, + `paths` is a list of :class:`~plexapi.library.Path` objects, and + `files` is a list of :class:`~plexapi.library.File` objects. + + Parameters: + path (:class:`~plexapi.library.Path` or str, optional): Full path to walk. + """ + paths = [] + files = [] + for item in self.browse(path): + if isinstance(item, Path): + paths.append(item) + elif isinstance(item, File): + files.append(item) + + if isinstance(path, Path): + path = path.path + + yield path or '', paths, files + + for _path in paths: + for path, paths, files in self.walk(_path): + yield path, paths, files + + def clients(self): + """ Returns list of all :class:`~plexapi.client.PlexClient` objects connected to server. """ + items = [] + ports = None + for elem in self.query('/clients'): + port = elem.attrib.get('port') + if not port: + log.warning('%s did not advertise a port, checking plex.tv.', elem.attrib.get('name')) + ports = self._myPlexClientPorts() if ports is None else ports + port = ports.get(elem.attrib.get('machineIdentifier')) + baseurl = 'http://%s:%s' % (elem.attrib['host'], port) + items.append(PlexClient(baseurl=baseurl, server=self, + token=self._token, data=elem, connect=False)) + + return items + + def client(self, name): + """ Returns the :class:`~plexapi.client.PlexClient` that matches the specified name. + + Parameters: + name (str): Name of the client to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown client name. + """ + for client in self.clients(): + if client and client.title == name: + return client + + raise NotFound('Unknown client name: %s' % name) + + def createCollection(self, title, section, items=None, smart=False, limit=None, + libtype=None, sort=None, filters=None, **kwargs): + """ Creates and returns a new :class:`~plexapi.collection.Collection`. + + Parameters: + title (str): Title of the collection. + section (:class:`~plexapi.library.LibrarySection`, str): The library section to create the collection in. + items (List): Regular collections only, list of :class:`~plexapi.audio.Audio`, + :class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the collection. + smart (bool): True to create a smart collection. Default False. + limit (int): Smart collections only, limit the number of items in the collection. + libtype (str): Smart collections only, the specific type of content to filter + (movie, show, season, episode, artist, album, track, photoalbum, photo). + sort (str or list, optional): Smart collections only, a string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): Smart collections only, a dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Smart collections only, additional custom filters to apply to the + search results. See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When no items are included to create the collection. + :class:`plexapi.exceptions.BadRequest`: When mixing media types in the collection. + + Returns: + :class:`~plexapi.collection.Collection`: A new instance of the created Collection. + """ + return Collection.create( + self, title, section, items=items, smart=smart, limit=limit, + libtype=libtype, sort=sort, filters=filters, **kwargs) + + def createPlaylist(self, title, section=None, items=None, smart=False, limit=None, + libtype=None, sort=None, filters=None, **kwargs): + """ Creates and returns a new :class:`~plexapi.playlist.Playlist`. + + Parameters: + title (str): Title of the playlist. + section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists only, + library section to create the playlist in. + items (List): Regular playlists only, list of :class:`~plexapi.audio.Audio`, + :class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the playlist. + smart (bool): True to create a smart playlist. Default False. + limit (int): Smart playlists only, limit the number of items in the playlist. + libtype (str): Smart playlists only, the specific type of content to filter + (movie, show, season, episode, artist, album, track, photoalbum, photo). + sort (str or list, optional): Smart playlists only, a string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): Smart playlists only, a dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Smart playlists only, additional custom filters to apply to the + search results. See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When no items are included to create the playlist. + :class:`plexapi.exceptions.BadRequest`: When mixing media types in the playlist. + + Returns: + :class:`~plexapi.playlist.Playlist`: A new instance of the created Playlist. + """ + return Playlist.create( + self, title, section=section, items=items, smart=smart, limit=limit, + libtype=libtype, sort=sort, filters=filters, **kwargs) + + def createPlayQueue(self, item, **kwargs): + """ Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`. + + Parameters: + item (Media or Playlist): Media or playlist to add to PlayQueue. + kwargs (dict): See `~plexapi.playqueue.PlayQueue.create`. + """ + return PlayQueue.create(self, item, **kwargs) + + def downloadDatabases(self, savepath=None, unpack=False): + """ Download databases. + + Parameters: + savepath (str): Defaults to current working dir. + unpack (bool): Unpack the zip file. + """ + url = self.url('/diagnostics/databases') + filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack) + return filepath + + def downloadLogs(self, savepath=None, unpack=False): + """ Download server logs. + + Parameters: + savepath (str): Defaults to current working dir. + unpack (bool): Unpack the zip file. + """ + url = self.url('/diagnostics/logs') + filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack) + return filepath + + @deprecated('use "checkForUpdate" instead') + def check_for_update(self, force=True, download=False): + return self.checkForUpdate() + + def checkForUpdate(self, force=True, download=False): + """ Returns a :class:`~plexapi.base.Release` object containing release info. + + Parameters: + force (bool): Force server to check for new releases + download (bool): Download if a update is available. + """ + part = '/updater/check?download=%s' % (1 if download else 0) + if force: + self.query(part, method=self._session.put) + releases = self.fetchItems('/updater/status') + if len(releases): + return releases[0] + + def isLatest(self): + """ Check if the installed version of PMS is the latest. """ + release = self.checkForUpdate(force=True) + return release is None + + def installUpdate(self): + """ Install the newest version of Plex Media Server. """ + # We can add this but dunno how useful this is since it sometimes + # requires user action using a gui. + part = '/updater/apply' + release = self.checkForUpdate(force=True, download=True) + if release and release.version != self.version: + # figure out what method this is.. + return self.query(part, method=self._session.put) + + def history(self, maxresults=9999999, mindate=None, ratingKey=None, accountID=None, librarySectionID=None): + """ Returns a list of media items from watched history. If there are many results, they will + be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only + looking for the first <num> results, it would be wise to set the maxresults option to that + amount so this functions doesn't iterate over all results on the server. + + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. This really helps speed + up the result listing. For example: datetime.now() - timedelta(days=7) + ratingKey (int/str) Request history for a specific ratingKey item. + accountID (int/str) Request history for a specific account ID. + librarySectionID (int/str) Request history for a specific library section ID. + """ + results, subresults = [], '_init' + args = {'sort': 'viewedAt:desc'} + if ratingKey: + args['metadataItemID'] = ratingKey + if accountID: + args['accountID'] = accountID + if librarySectionID: + args['librarySectionID'] = librarySectionID + if mindate: + args['viewedAt>'] = int(mindate.timestamp()) + args['X-Plex-Container-Start'] = 0 + args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults) + while subresults and maxresults > len(results): + key = '/status/sessions/history/all%s' % utils.joinArgs(args) + subresults = self.fetchItems(key) + results += subresults[:maxresults - len(results)] + args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] + return results + + def playlists(self, playlistType=None, sectionId=None, title=None, sort=None, **kwargs): + """ Returns a list of all :class:`~plexapi.playlist.Playlist` objects on the server. + + Parameters: + playlistType (str, optional): The type of playlists to return (audio, video, photo). + Default returns all playlists. + sectionId (int, optional): The section ID (key) of the library to search within. + title (str, optional): General string query to search for. Partial string matches are allowed. + sort (str or list, optional): A string of comma separated sort fields in the format ``column:dir``. + """ + args = {} + if playlistType is not None: + args['playlistType'] = playlistType + if sectionId is not None: + args['sectionID'] = sectionId + if title is not None: + args['title'] = title + if sort is not None: + # TODO: Automatically retrieve and validate sort field similar to LibrarySection.search() + args['sort'] = sort + + key = '/playlists%s' % utils.joinArgs(args) + return self.fetchItems(key, **kwargs) + + def playlist(self, title): + """ Returns the :class:`~plexapi.client.Playlist` that matches the specified title. + + Parameters: + title (str): Title of the playlist to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unable to find playlist. + """ + try: + return self.playlists(title=title, title__iexact=title)[0] + except IndexError: + raise NotFound('Unable to find playlist with title "%s".' % title) from None + + def optimizedItems(self, removeAll=None): + """ Returns list of all :class:`~plexapi.media.Optimized` objects connected to server. """ + if removeAll is True: + key = '/playlists/generators?type=42' + self.query(key, method=self._server._session.delete) + else: + backgroundProcessing = self.fetchItem('/playlists?type=42') + return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized) + + @deprecated('use "plexapi.media.Optimized.items()" instead') + def optimizedItem(self, optimizedID): + """ Returns single queued optimized item :class:`~plexapi.media.Video` object. + Allows for using optimized item ID to connect back to source item. + """ + + backgroundProcessing = self.fetchItem('/playlists?type=42') + return self.fetchItem('%s/items/%s/items' % (backgroundProcessing.key, optimizedID)) + + def conversions(self, pause=None): + """ Returns list of all :class:`~plexapi.media.Conversion` objects connected to server. """ + if pause is True: + self.query('/:/prefs?BackgroundQueueIdlePaused=1', method=self._server._session.put) + elif pause is False: + self.query('/:/prefs?BackgroundQueueIdlePaused=0', method=self._server._session.put) + else: + return self.fetchItems('/playQueues/1', cls=Conversion) + + def currentBackgroundProcess(self): + """ Returns list of all :class:`~plexapi.media.TranscodeJob` objects running or paused on server. """ + return self.fetchItems('/status/sessions/background') + + def query(self, key, method=None, headers=None, timeout=None, **kwargs): + """ Main method used to handle HTTPS requests to the Plex server. This method helps + by encoding the response to utf-8 and parsing the returned XML into and + ElementTree object. Returns None if no data exists in the response. + """ + url = self.url(key) + method = method or self._session.get + timeout = timeout or TIMEOUT + log.debug('%s %s', method.__name__.upper(), url) + headers = self._headers(**headers or {}) + response = method(url, headers=headers, timeout=timeout, **kwargs) + if response.status_code not in (200, 201, 204): + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) + if response.status_code == 401: + raise Unauthorized(message) + elif response.status_code == 404: + raise NotFound(message) + else: + raise BadRequest(message) + data = response.text.encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None + + def search(self, query, mediatype=None, limit=None, sectionId=None): + """ Returns a list of media items or filter categories from the resulting + `Hub Search <https://www.plex.tv/blog/seek-plex-shall-find-leveling-web-app/>`_ + against all items in your Plex library. This searches genres, actors, directors, + playlists, as well as all the obvious media titles. It performs spell-checking + against your search terms (because KUROSAWA is hard to spell). It also provides + contextual search results. So for example, if you search for 'Pernice', it’ll + return 'Pernice Brothers' as the artist result, but we’ll also go ahead and + return your most-listened to albums and tracks from the artist. If you type + 'Arnold' you’ll get a result for the actor, but also the most recently added + movies he’s in. + + Parameters: + query (str): Query to use when searching your library. + mediatype (str, optional): Limit your search to the specified media type. + actor, album, artist, autotag, collection, director, episode, game, genre, + movie, photo, photoalbum, place, playlist, shared, show, tag, track + limit (int, optional): Limit to the specified number of results per Hub. + sectionId (int, optional): The section ID (key) of the library to search within. + """ + results = [] + params = { + 'query': query, + 'includeCollections': 1, + 'includeExternalMedia': 1} + if limit: + params['limit'] = limit + if sectionId: + params['sectionId'] = sectionId + key = '/hubs/search?%s' % urlencode(params) + for hub in self.fetchItems(key, Hub): + if mediatype: + if hub.type == mediatype: + return hub.items + else: + results += hub.items + return results + + def sessions(self): + """ Returns a list of all active session (currently playing) media objects. """ + return self.fetchItems('/status/sessions') + + def transcodeSessions(self): + """ Returns a list of all active :class:`~plexapi.media.TranscodeSession` objects. """ + return self.fetchItems('/transcode/sessions') + + def startAlertListener(self, callback=None): + """ Creates a websocket connection to the Plex Server to optionally recieve + notifications. These often include messages from Plex about media scans + as well as updates to currently running Transcode Sessions. + + NOTE: You need websocket-client installed in order to use this feature. + >> pip install websocket-client + + Parameters: + callback (func): Callback function to call on recieved messages. + + Raises: + :exc:`~plexapi.exception.Unsupported`: Websocket-client not installed. + """ + notifier = AlertListener(self, callback) + notifier.start() + return notifier + + def transcodeImage(self, imageUrl, height, width, + opacity=None, saturation=None, blur=None, background=None, + minSize=True, upscale=True, imageFormat=None): + """ Returns the URL for a transcoded image. + + Parameters: + imageUrl (str): The URL to the image + (eg. returned by :func:`~plexapi.mixins.PosterUrlMixin.thumbUrl` + or :func:`~plexapi.mixins.ArtUrlMixin.artUrl`). + The URL can be an online image. + height (int): Height to transcode the image to. + width (int): Width to transcode the image to. + opacity (int, optional): Change the opacity of the image (0 to 100) + saturation (int, optional): Change the saturation of the image (0 to 100). + blur (int, optional): The blur to apply to the image in pixels (e.g. 3). + background (str, optional): The background hex colour to apply behind the opacity (e.g. '000000'). + minSize (bool, optional): Maintain smallest dimension. Default True. + upscale (bool, optional): Upscale the image if required. Default True. + imageFormat (str, optional): 'jpeg' (default) or 'png'. + """ + params = { + 'url': imageUrl, + 'height': height, + 'width': width, + 'minSize': int(bool(minSize)), + 'upscale': int(bool(upscale)) + } + if opacity is not None: + params['opacity'] = opacity + if saturation is not None: + params['saturation'] = saturation + if blur is not None: + params['blur'] = blur + if background is not None: + params['background'] = str(background).strip('#') + if imageFormat is not None: + params['format'] = imageFormat.lower() + + key = '/photo/:/transcode%s' % utils.joinArgs(params) + return self.url(key, includeToken=True) + + def url(self, key, includeToken=None): + """ Build a URL string with proper token argument. Token will be appended to the URL + if either includeToken is True or CONFIG.log.show_secrets is 'true'. + """ + if self._token and (includeToken or self._showSecrets): + delim = '&' if '?' in key else '?' + return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token) + return '%s%s' % (self._baseurl, key) + + def refreshSynclist(self): + """ Force PMS to download new SyncList from Plex.tv. """ + return self.query('/sync/refreshSynclists', self._session.put) + + def refreshContent(self): + """ Force PMS to refresh content for known SyncLists. """ + return self.query('/sync/refreshContent', self._session.put) + + def refreshSync(self): + """ Calls :func:`~plexapi.server.PlexServer.refreshSynclist` and + :func:`~plexapi.server.PlexServer.refreshContent`, just like the Plex Web UI does when you click 'refresh'. + """ + self.refreshSynclist() + self.refreshContent() + + def _allowMediaDeletion(self, toggle=False): + """ Toggle allowMediaDeletion. + Parameters: + toggle (bool): True enables Media Deletion + False or None disable Media Deletion (Default) + """ + if self.allowMediaDeletion and toggle is False: + log.debug('Plex is currently allowed to delete media. Toggling off.') + elif self.allowMediaDeletion and toggle is True: + log.debug('Plex is currently allowed to delete media. Toggle set to allow, exiting.') + raise BadRequest('Plex is currently allowed to delete media. Toggle set to allow, exiting.') + elif self.allowMediaDeletion is None and toggle is True: + log.debug('Plex is currently not allowed to delete media. Toggle set to allow.') + else: + log.debug('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.') + raise BadRequest('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.') + value = 1 if toggle is True else 0 + return self.query('/:/prefs?allowMediaDeletion=%s' % value, self._session.put) + + def bandwidth(self, timespan=None, **kwargs): + """ Returns a list of :class:`~plexapi.server.StatisticsBandwidth` objects + with the Plex server dashboard bandwidth data. + + Parameters: + timespan (str, optional): The timespan to bin the bandwidth data. Default is seconds. + Available timespans: seconds, hours, days, weeks, months. + **kwargs (dict, optional): Any of the available filters that can be applied to the bandwidth data. + The time frame (at) and bytes can also be filtered using less than or greater than (see examples below). + + * accountID (int): The :class:`~plexapi.server.SystemAccount` ID to filter. + * at (datetime): The time frame to filter (inclusive). The time frame can be either: + 1. An exact time frame (e.g. Only December 1st 2020 `at=datetime(2020, 12, 1)`). + 2. Before a specific time (e.g. Before and including December 2020 `at<=datetime(2020, 12, 1)`). + 3. After a specific time (e.g. After and including January 2021 `at>=datetime(2021, 1, 1)`). + * bytes (int): The amount of bytes to filter (inclusive). The bytes can be either: + 1. An exact number of bytes (not very useful) (e.g. `bytes=1024**3`). + 2. Less than or equal number of bytes (e.g. `bytes<=1024**3`). + 3. Greater than or equal number of bytes (e.g. `bytes>=1024**3`). + * deviceID (int): The :class:`~plexapi.server.SystemDevice` ID to filter. + * lan (bool): True to only retrieve local bandwidth, False to only retrieve remote bandwidth. + Default returns all local and remote bandwidth. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When applying an invalid timespan or unknown filter. + + Example: + + .. code-block:: python + + from plexapi.server import PlexServer + plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx') + + # Filter bandwidth data for December 2020 and later, and more than 1 GB used. + filters = { + 'at>': datetime(2020, 12, 1), + 'bytes>': 1024**3 + } + + # Retrieve bandwidth data in one day timespans. + bandwidthData = plex.bandwidth(timespan='days', **filters) + + # Print out bandwidth usage for each account and device combination. + for bandwidth in sorted(bandwidthData, key=lambda x: x.at): + account = bandwidth.account() + device = bandwidth.device() + gigabytes = round(bandwidth.bytes / 1024**3, 3) + local = 'local' if bandwidth.lan else 'remote' + date = bandwidth.at.strftime('%Y-%m-%d') + print('%s used %s GB of %s bandwidth on %s from %s' + % (account.name, gigabytes, local, date, device.name)) + + """ + params = {} + + if timespan is None: + params['timespan'] = 6 # Default to seconds + else: + timespans = { + 'seconds': 6, + 'hours': 4, + 'days': 3, + 'weeks': 2, + 'months': 1 + } + try: + params['timespan'] = timespans[timespan] + except KeyError: + raise BadRequest('Invalid timespan specified: %s. ' + 'Available timespans: %s' % (timespan, ', '.join(list(timespans.keys())))) + + filters = {'accountID', 'at', 'at<', 'at>', 'bytes', 'bytes<', 'bytes>', 'deviceID', 'lan'} + + for key, value in list(kwargs.items()): + if key not in filters: + raise BadRequest('Unknown filter: %s=%s' % (key, value)) + if key.startswith('at'): + try: + value = utils.cast(int, value.timestamp()) + except AttributeError: + raise BadRequest('Time frame filter must be a datetime object: %s=%s' % (key, value)) + elif key.startswith('bytes') or key == 'lan': + value = utils.cast(int, value) + elif key == 'accountID': + if value == self.myPlexAccount().id: + value = 1 # The admin account is accountID=1 + params[key] = value + + key = '/statistics/bandwidth?%s' % urlencode(params) + return self.fetchItems(key, StatisticsBandwidth) + + def resources(self): + """ Returns a list of :class:`~plexapi.server.StatisticsResources` objects + with the Plex server dashboard resources data. """ + key = '/statistics/resources?timespan=6' + return self.fetchItems(key, StatisticsResources) + + def _buildWebURL(self, base=None, endpoint=None, **kwargs): + """ Build the Plex Web URL for the object. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + endpoint (str): The Plex Web URL endpoint. + None for server, 'playlist' for playlists, 'details' for all other media types. + **kwargs (dict): Dictionary of URL parameters. + """ + if base is None: + base = 'https://app.plex.tv/desktop/' + + if endpoint: + return '%s#!/server/%s/%s%s' % ( + base, self.machineIdentifier, endpoint, utils.joinArgs(kwargs) + ) + else: + return '%s#!/media/%s/com.plexapp.plugins.library%s' % ( + base, self.machineIdentifier, utils.joinArgs(kwargs) + ) + + def getWebURL(self, base=None, playlistTab=None): + """ Returns the Plex Web URL for the server. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + playlistTab (str): The playlist tab (audio, video, photo). Only used for the playlist URL. + """ + if playlistTab is not None: + params = {'source': 'playlists', 'pivot': 'playlists.%s' % playlistTab} + else: + params = {'key': '/hubs', 'pageType': 'hub'} + return self._buildWebURL(base=base, **params) + + +class Account(PlexObject): + """ Contains the locally cached MyPlex account information. The properties provided don't + match the :class:`~plexapi.myplex.MyPlexAccount` object very well. I believe this exists + because access to myplex is not required to get basic plex information. I can't imagine + object is terribly useful except unless you were needed this information while offline. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): PlexServer this account is connected to (optional) + data (ElementTree): Response from PlexServer used to build this object (optional). + + Attributes: + authToken (str): Plex authentication token to access the server. + mappingError (str): Unknown + mappingErrorMessage (str): Unknown + mappingState (str): Unknown + privateAddress (str): Local IP address of the Plex server. + privatePort (str): Local port of the Plex server. + publicAddress (str): Public IP address of the Plex server. + publicPort (str): Public port of the Plex server. + signInState (str): Signin state for this account (ex: ok). + subscriptionActive (str): True if the account subscription is active. + subscriptionFeatures (str): List of features allowed by the server for this account. + This may be based on your PlexPass subscription. Features include: camera_upload, + cloudsync, content_filter, dvr, hardware_transcoding, home, lyrics, music_videos, + pass, photo_autotags, premium_music_metadata, session_bandwidth_restrictions, + sync, trailers, webhooks' (and maybe more). + subscriptionState (str): 'Active' if this subscription is active. + username (str): Plex account username (user@example.com). + """ + key = '/myplex/account' + + def _loadData(self, data): + self._data = data + self.authToken = data.attrib.get('authToken') + self.username = data.attrib.get('username') + self.mappingState = data.attrib.get('mappingState') + self.mappingError = data.attrib.get('mappingError') + self.mappingErrorMessage = data.attrib.get('mappingErrorMessage') + self.signInState = data.attrib.get('signInState') + self.publicAddress = data.attrib.get('publicAddress') + self.publicPort = data.attrib.get('publicPort') + self.privateAddress = data.attrib.get('privateAddress') + self.privatePort = data.attrib.get('privatePort') + self.subscriptionFeatures = utils.toList(data.attrib.get('subscriptionFeatures')) + self.subscriptionActive = utils.cast(bool, data.attrib.get('subscriptionActive')) + self.subscriptionState = data.attrib.get('subscriptionState') + + +class Activity(PlexObject): + """A currently running activity on the PlexServer.""" + key = '/activities' + + def _loadData(self, data): + self._data = data + self.cancellable = utils.cast(bool, data.attrib.get('cancellable')) + self.progress = utils.cast(int, data.attrib.get('progress')) + self.title = data.attrib.get('title') + self.subtitle = data.attrib.get('subtitle') + self.type = data.attrib.get('type') + self.uuid = data.attrib.get('uuid') + + +@utils.registerPlexObject +class Release(PlexObject): + TAG = 'Release' + key = '/updater/status' + + def _loadData(self, data): + self.download_key = data.attrib.get('key') + self.version = data.attrib.get('version') + self.added = data.attrib.get('added') + self.fixed = data.attrib.get('fixed') + self.downloadURL = data.attrib.get('downloadURL') + self.state = data.attrib.get('state') + + +class SystemAccount(PlexObject): + """ Represents a single system account. + + Attributes: + TAG (str): 'Account' + autoSelectAudio (bool): True or False if the account has automatic audio language enabled. + defaultAudioLanguage (str): The default audio language code for the account. + defaultSubtitleLanguage (str): The default subtitle language code for the account. + id (int): The Plex account ID. + key (str): API URL (/accounts/<id>) + name (str): The username of the account. + subtitleMode (bool): The subtitle mode for the account. + thumb (str): URL for the account thumbnail. + """ + TAG = 'Account' + + def _loadData(self, data): + self._data = data + self.autoSelectAudio = utils.cast(bool, data.attrib.get('autoSelectAudio')) + self.defaultAudioLanguage = data.attrib.get('defaultAudioLanguage') + self.defaultSubtitleLanguage = data.attrib.get('defaultSubtitleLanguage') + self.id = utils.cast(int, data.attrib.get('id')) + self.key = data.attrib.get('key') + self.name = data.attrib.get('name') + self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode')) + self.thumb = data.attrib.get('thumb') + # For backwards compatibility + self.accountID = self.id + self.accountKey = self.key + + +class SystemDevice(PlexObject): + """ Represents a single system device. + + Attributes: + TAG (str): 'Device' + clientIdentifier (str): The unique identifier for the device. + createdAt (datatime): Datetime the device was created. + id (int): The ID of the device (not the same as :class:`~plexapi.myplex.MyPlexDevice` ID). + key (str): API URL (/devices/<id>) + name (str): The name of the device. + platform (str): OS the device is running (Linux, Windows, Chrome, etc.) + """ + TAG = 'Device' + + def _loadData(self, data): + self._data = data + self.clientIdentifier = data.attrib.get('clientIdentifier') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.id = utils.cast(int, data.attrib.get('id')) + self.key = '/devices/%s' % self.id + self.name = data.attrib.get('name') + self.platform = data.attrib.get('platform') + + +class StatisticsBandwidth(PlexObject): + """ Represents a single statistics bandwidth data. + + Attributes: + TAG (str): 'StatisticsBandwidth' + accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. + at (datatime): Datetime of the bandwidth data. + bytes (int): The total number of bytes for the specified timespan. + deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID. + lan (bool): True or False wheter the bandwidth is local or remote. + timespan (int): The timespan for the bandwidth data. + 1: months, 2: weeks, 3: days, 4: hours, 6: seconds. + + """ + TAG = 'StatisticsBandwidth' + + def _loadData(self, data): + self._data = data + self.accountID = utils.cast(int, data.attrib.get('accountID')) + self.at = utils.toDatetime(data.attrib.get('at')) + self.bytes = utils.cast(int, data.attrib.get('bytes')) + self.deviceID = utils.cast(int, data.attrib.get('deviceID')) + self.lan = utils.cast(bool, data.attrib.get('lan')) + self.timespan = utils.cast(int, data.attrib.get('timespan')) + + def __repr__(self): + return '<%s>' % ':'.join([p for p in [ + self.__class__.__name__, + self._clean(self.accountID), + self._clean(self.deviceID), + self._clean(int(self.at.timestamp())) + ] if p]) + + def account(self): + """ Returns the :class:`~plexapi.server.SystemAccount` associated with the bandwidth data. """ + return self._server.systemAccount(self.accountID) + + def device(self): + """ Returns the :class:`~plexapi.server.SystemDevice` associated with the bandwidth data. """ + return self._server.systemDevice(self.deviceID) + + +class StatisticsResources(PlexObject): + """ Represents a single statistics resources data. + + Attributes: + TAG (str): 'StatisticsResources' + at (datatime): Datetime of the resource data. + hostCpuUtilization (float): The system CPU usage %. + hostMemoryUtilization (float): The Plex Media Server CPU usage %. + processCpuUtilization (float): The system RAM usage %. + processMemoryUtilization (float): The Plex Media Server RAM usage %. + timespan (int): The timespan for the resource data (6: seconds). + """ + TAG = 'StatisticsResources' + + def _loadData(self, data): + self._data = data + self.at = utils.toDatetime(data.attrib.get('at')) + self.hostCpuUtilization = utils.cast(float, data.attrib.get('hostCpuUtilization')) + self.hostMemoryUtilization = utils.cast(float, data.attrib.get('hostMemoryUtilization')) + self.processCpuUtilization = utils.cast(float, data.attrib.get('processCpuUtilization')) + self.processMemoryUtilization = utils.cast(float, data.attrib.get('processMemoryUtilization')) + self.timespan = utils.cast(int, data.attrib.get('timespan')) + + def __repr__(self): + return '<%s>' % ':'.join([p for p in [ + self.__class__.__name__, + self._clean(int(self.at.timestamp())) + ] if p]) diff --git a/service.plexskipintro/plexapi/settings.py b/service.plexskipintro/plexapi/settings.py new file mode 100644 index 0000000000..7733feabba --- /dev/null +++ b/service.plexskipintro/plexapi/settings.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +from collections import defaultdict +from urllib.parse import quote + +from plexapi import log, utils +from plexapi.base import PlexObject +from plexapi.exceptions import BadRequest, NotFound + + +class Settings(PlexObject): + """ Container class for all settings. Allows getting and setting PlexServer settings. + + Attributes: + key (str): '/:/prefs' + """ + key = '/:/prefs' + + def __init__(self, server, data, initpath=None): + self._settings = {} + super(Settings, self).__init__(server, data, initpath) + + def __getattr__(self, attr): + if attr.startswith('_'): + try: + return self.__dict__[attr] + except KeyError: + raise AttributeError + return self.get(attr).value + + def __setattr__(self, attr, value): + if not attr.startswith('_'): + return self.get(attr).set(value) + self.__dict__[attr] = value + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + for elem in data: + id = utils.lowerFirst(elem.attrib['id']) + if id in self._settings: + self._settings[id]._loadData(elem) + continue + self._settings[id] = Setting(self._server, elem, self._initpath) + + def all(self): + """ Returns a list of all :class:`~plexapi.settings.Setting` objects available. """ + return [v for id, v in sorted(self._settings.items())] + + def get(self, id): + """ Return the :class:`~plexapi.settings.Setting` object with the specified id. """ + id = utils.lowerFirst(id) + if id in self._settings: + return self._settings[id] + raise NotFound('Invalid setting id: %s' % id) + + def groups(self): + """ Returns a dict of lists for all :class:`~plexapi.settings.Setting` + objects grouped by setting group. + """ + groups = defaultdict(list) + for setting in self.all(): + groups[setting.group].append(setting) + return dict(groups) + + def group(self, group): + """ Return a list of all :class:`~plexapi.settings.Setting` objects in the specified group. + + Parameters: + group (str): Group to return all settings. + """ + return self.groups().get(group, []) + + def save(self): + """ Save any outstanding settnig changes to the :class:`~plexapi.server.PlexServer`. This + performs a full reload() of Settings after complete. + """ + params = {} + for setting in self.all(): + if setting._setValue: + log.info('Saving PlexServer setting %s = %s' % (setting.id, setting._setValue)) + params[setting.id] = quote(setting._setValue) + if not params: + raise BadRequest('No setting have been modified.') + querystr = '&'.join(['%s=%s' % (k, v) for k, v in list(params.items())]) + url = '%s?%s' % (self.key, querystr) + self._server.query(url, self._server._session.put) + self.reload() + + +class Setting(PlexObject): + """ Represents a single Plex setting. + + Attributes: + id (str): Setting id (or name). + label (str): Short description of what this setting is. + summary (str): Long description of what this setting is. + type (str): Setting type (text, int, double, bool). + default (str): Default value for this setting. + value (str,bool,int,float): Current value for this setting. + hidden (bool): True if this is a hidden setting. + advanced (bool): True if this is an advanced setting. + group (str): Group name this setting is categorized as. + enumValues (list,dict): List or dictionary of valis values for this setting. + """ + _bool_cast = lambda x: bool(x == 'true' or x == '1') + _bool_str = lambda x: str(x).lower() + TYPES = { + 'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str}, + 'double': {'type': float, 'cast': float, 'tostr': str}, + 'int': {'type': int, 'cast': int, 'tostr': str}, + 'text': {'type': str, 'cast': str, 'tostr': str}, + } + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._setValue = None + self.id = data.attrib.get('id') + self.label = data.attrib.get('label') + self.summary = data.attrib.get('summary') + self.type = data.attrib.get('type') + self.default = self._cast(data.attrib.get('default')) + self.value = self._cast(data.attrib.get('value')) + self.hidden = utils.cast(bool, data.attrib.get('hidden')) + self.advanced = utils.cast(bool, data.attrib.get('advanced')) + self.group = data.attrib.get('group') + self.enumValues = self._getEnumValues(data) + + def _cast(self, value): + """ Cast the specific value to the type of this setting. """ + if self.type != 'enum': + value = utils.cast(self.TYPES.get(self.type)['cast'], value) + return value + + def _getEnumValues(self, data): + """ Returns a list of dictionary of valis value for this setting. """ + enumstr = data.attrib.get('enumValues') + if not enumstr: + return None + if ':' in enumstr: + return {self._cast(k): v for k, v in [kv.split(':') for kv in enumstr.split('|')]} + return enumstr.split('|') + + def set(self, value): + """ Set a new value for this setitng. NOTE: You must call plex.settings.save() for before + any changes to setting values are persisted to the :class:`~plexapi.server.PlexServer`. + """ + # check a few things up front + if not isinstance(value, self.TYPES[self.type]['type']): + badtype = type(value).__name__ + raise BadRequest('Invalid value for %s: a %s is required, not %s' % (self.id, self.type, badtype)) + if self.enumValues and value not in self.enumValues: + raise BadRequest('Invalid value for %s: %s not in %s' % (self.id, value, list(self.enumValues))) + # store value off to the side until we call settings.save() + tostr = self.TYPES[self.type]['tostr'] + self._setValue = tostr(value) + + def toUrl(self): + """Helper for urls""" + return '%s=%s' % (self.id, self._value or self.value) + + +@utils.registerPlexObject +class Preferences(Setting): + """ Represents a single Preferences. + + Attributes: + TAG (str): 'Setting' + FILTER (str): 'preferences' + """ + TAG = 'Setting' + FILTER = 'preferences' + + def _default(self): + """ Set the default value for this setting.""" + key = '%s/prefs?' % self._initpath + url = key + '%s=%s' % (self.id, self.default) + self._server.query(url, method=self._server._session.put) diff --git a/service.plexskipintro/plexapi/sonos.py b/service.plexskipintro/plexapi/sonos.py new file mode 100644 index 0000000000..3bdfc1f218 --- /dev/null +++ b/service.plexskipintro/plexapi/sonos.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +import requests +from plexapi import CONFIG, X_PLEX_IDENTIFIER +from plexapi.client import PlexClient +from plexapi.exceptions import BadRequest +from plexapi.playqueue import PlayQueue + + +class PlexSonosClient(PlexClient): + """ Class for interacting with a Sonos speaker via the Plex API. This class + makes requests to an external Plex API which then forwards the + Sonos-specific commands back to your Plex server & Sonos speakers. Use + of this feature requires an active Plex Pass subscription and Sonos + speakers linked to your Plex account. It also requires remote access to + be working properly. + + More details on the Sonos integration are avaialble here: + https://support.plex.tv/articles/218237558-requirements-for-using-plex-for-sonos/ + + The Sonos API emulates the Plex player control API closely: + https://github.com/plexinc/plex-media-player/wiki/Remote-control-API + + Parameters: + account (:class:`~plexapi.myplex.PlexAccount`): PlexAccount instance this + Sonos speaker is associated with. + data (ElementTree): Response from Plex Sonos API used to build this client. + + Attributes: + deviceClass (str): "speaker" + lanIP (str): Local IP address of speaker. + machineIdentifier (str): Unique ID for this device. + platform (str): "Sonos" + platformVersion (str): Build version of Sonos speaker firmware. + product (str): "Sonos" + protocol (str): "plex" + protocolCapabilities (list<str>): List of client capabilities (timeline, playback, + playqueues, provider-playback) + server (:class:`~plexapi.server.PlexServer`): Server this client is connected to. + session (:class:`~requests.Session`): Session object used for connection. + title (str): Name of this Sonos speaker. + token (str): X-Plex-Token used for authenication + _baseurl (str): Address of public Plex Sonos API endpoint. + _commandId (int): Counter for commands sent to Plex API. + _token (str): Token associated with linked Plex account. + _session (obj): Requests session object used to access this client. + """ + + def __init__(self, account, data): + self._data = data + self.deviceClass = data.attrib.get("deviceClass") + self.machineIdentifier = data.attrib.get("machineIdentifier") + self.product = data.attrib.get("product") + self.platform = data.attrib.get("platform") + self.platformVersion = data.attrib.get("platformVersion") + self.protocol = data.attrib.get("protocol") + self.protocolCapabilities = data.attrib.get("protocolCapabilities") + self.lanIP = data.attrib.get("lanIP") + self.title = data.attrib.get("title") + self._baseurl = "https://sonos.plex.tv" + self._commandId = 0 + self._token = account._token + self._session = account._session or requests.Session() + + # Dummy values for PlexClient inheritance + self._last_call = 0 + self._proxyThroughServer = False + self._showSecrets = CONFIG.get("log.show_secrets", "").lower() == "true" + + def playMedia(self, media, offset=0, **params): + + if hasattr(media, "playlistType"): + mediatype = media.playlistType + else: + if isinstance(media, PlayQueue): + mediatype = media.items[0].listType + else: + mediatype = media.listType + + if mediatype == "audio": + mediatype = "music" + else: + raise BadRequest("Sonos currently only supports music for playback") + + server_protocol, server_address, server_port = media._server._baseurl.split(":") + server_address = server_address.strip("/") + server_port = server_port.strip("/") + + playqueue = ( + media + if isinstance(media, PlayQueue) + else media._server.createPlayQueue(media) + ) + self.sendCommand( + "playback/playMedia", + **dict( + { + "type": "music", + "providerIdentifier": "com.plexapp.plugins.library", + "containerKey": "/playQueues/{}?own=1".format( + playqueue.playQueueID + ), + "key": media.key, + "offset": offset, + "machineIdentifier": media._server.machineIdentifier, + "protocol": server_protocol, + "address": server_address, + "port": server_port, + "token": media._server.createToken(), + "commandID": self._nextCommandId(), + "X-Plex-Client-Identifier": X_PLEX_IDENTIFIER, + "X-Plex-Token": media._server._token, + "X-Plex-Target-Client-Identifier": self.machineIdentifier, + }, + **params + ) + ) diff --git a/service.plexskipintro/plexapi/sync.py b/service.plexskipintro/plexapi/sync.py new file mode 100644 index 0000000000..53dc0636f4 --- /dev/null +++ b/service.plexskipintro/plexapi/sync.py @@ -0,0 +1,312 @@ +# -*- coding: utf-8 -*- +""" +You can work with Mobile Sync on other devices straight away, but if you'd like to use your app as a `sync-target` (when +you can set items to be synced to your app) you need to init some variables. + +.. code-block:: python + + def init_sync(): + import plexapi + plexapi.X_PLEX_PROVIDES = 'sync-target' + plexapi.BASE_HEADERS['X-Plex-Sync-Version'] = '2' + plexapi.BASE_HEADERS['X-Plex-Provides'] = plexapi.X_PLEX_PROVIDES + + # mimic iPhone SE + plexapi.X_PLEX_PLATFORM = 'iOS' + plexapi.X_PLEX_PLATFORM_VERSION = '11.4.1' + plexapi.X_PLEX_DEVICE = 'iPhone' + + plexapi.BASE_HEADERS['X-Plex-Platform'] = plexapi.X_PLEX_PLATFORM + plexapi.BASE_HEADERS['X-Plex-Platform-Version'] = plexapi.X_PLEX_PLATFORM_VERSION + plexapi.BASE_HEADERS['X-Plex-Device'] = plexapi.X_PLEX_DEVICE + +You have to fake platform/device/model because transcoding profiles are hardcoded in Plex, and you obviously have +to explicitly specify that your app supports `sync-target`. +""" + +import requests + +import plexapi +from plexapi.base import PlexObject +from plexapi.exceptions import NotFound, BadRequest + + +class SyncItem(PlexObject): + """ + Represents single sync item, for specified server and client. When you saying in the UI to sync "this" to "that" + you're basically creating a sync item. + + Attributes: + id (int): unique id of the item. + clientIdentifier (str): an identifier of Plex Client device, to which the item is belongs. + machineIdentifier (str): the id of server which holds all this content. + version (int): current version of the item. Each time you modify the item (e.g. by changing amount if media to + sync) the new version is created. + rootTitle (str): the title of library/media from which the sync item was created. E.g.: + + * when you create an item for an episode 3 of season 3 of show Example, the value would be `Title of + Episode 3` + * when you create an item for a season 3 of show Example, the value would be `Season 3` + * when you set to sync all your movies in library named "My Movies" to value would be `My Movies`. + + title (str): the title which you've set when created the sync item. + metadataType (str): the type of media which hides inside, can be `episode`, `movie`, etc. + contentType (str): basic type of the content: `video` or `audio`. + status (:class:`~plexapi.sync.Status`): current status of the sync. + mediaSettings (:class:`~plexapi.sync.MediaSettings`): media transcoding settings used for the item. + policy (:class:`~plexapi.sync.Policy`): the policy of which media to sync. + location (str): plex-style library url with all required filters / sorting. + """ + TAG = 'SyncItem' + + def __init__(self, server, data, initpath=None, clientIdentifier=None): + super(SyncItem, self).__init__(server, data, initpath) + self.clientIdentifier = clientIdentifier + + def _loadData(self, data): + self._data = data + self.id = plexapi.utils.cast(int, data.attrib.get('id')) + self.version = plexapi.utils.cast(int, data.attrib.get('version')) + self.rootTitle = data.attrib.get('rootTitle') + self.title = data.attrib.get('title') + self.metadataType = data.attrib.get('metadataType') + self.contentType = data.attrib.get('contentType') + self.machineIdentifier = data.find('Server').get('machineIdentifier') + self.status = Status(**data.find('Status').attrib) + self.mediaSettings = MediaSettings(**data.find('MediaSettings').attrib) + self.policy = Policy(**data.find('Policy').attrib) + self.location = data.find('Location').attrib.get('uri', '') + + def server(self): + """ Returns :class:`~plexapi.myplex.MyPlexResource` with server of current item. """ + server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier] + if len(server) == 0: + raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier) + return server[0] + + def getMedia(self): + """ Returns list of :class:`~plexapi.base.Playable` which belong to this sync item. """ + server = self.server().connect() + key = '/sync/items/%s' % self.id + return server.fetchItems(key) + + def markDownloaded(self, media): + """ Mark the file as downloaded (by the nature of Plex it will be marked as downloaded within + any SyncItem where it presented). + + Parameters: + media (base.Playable): the media to be marked as downloaded. + """ + url = '/sync/%s/item/%s/downloaded' % (self.clientIdentifier, media.ratingKey) + media._server.query(url, method=requests.put) + + def delete(self): + """ Removes current SyncItem """ + url = SyncList.key.format(clientId=self.clientIdentifier) + url += '/' + str(self.id) + self._server.query(url, self._server._session.delete) + + +class SyncList(PlexObject): + """ Represents a Mobile Sync state, specific for single client, within one SyncList may be presented + items from different servers. + + Attributes: + clientId (str): an identifier of the client. + items (List<:class:`~plexapi.sync.SyncItem`>): list of registered items to sync. + """ + key = 'https://plex.tv/devices/{clientId}/sync_items' + TAG = 'SyncList' + + def _loadData(self, data): + self._data = data + self.clientId = data.attrib.get('clientIdentifier') + self.items = [] + + syncItems = data.find('SyncItems') + if syncItems: + for sync_item in syncItems.iter('SyncItem'): + item = SyncItem(self._server, sync_item, clientIdentifier=self.clientId) + self.items.append(item) + + +class Status(object): + """ Represents a current status of specific :class:`~plexapi.sync.SyncItem`. + + Attributes: + failureCode: unknown, never got one yet. + failure: unknown. + state (str): server-side status of the item, can be `completed`, `pending`, empty, and probably something + else. + itemsCount (int): total items count. + itemsCompleteCount (int): count of transcoded and/or downloaded items. + itemsDownloadedCount (int): count of downloaded items. + itemsReadyCount (int): count of transcoded items, which can be downloaded. + totalSize (int): total size in bytes of complete items. + itemsSuccessfulCount (int): unknown, in my experience it always was equal to `itemsCompleteCount`. + """ + + def __init__(self, itemsCount, itemsCompleteCount, state, totalSize, itemsDownloadedCount, itemsReadyCount, + itemsSuccessfulCount, failureCode, failure): + self.itemsDownloadedCount = plexapi.utils.cast(int, itemsDownloadedCount) + self.totalSize = plexapi.utils.cast(int, totalSize) + self.itemsReadyCount = plexapi.utils.cast(int, itemsReadyCount) + self.failureCode = failureCode + self.failure = failure + self.itemsSuccessfulCount = plexapi.utils.cast(int, itemsSuccessfulCount) + self.state = state + self.itemsCompleteCount = plexapi.utils.cast(int, itemsCompleteCount) + self.itemsCount = plexapi.utils.cast(int, itemsCount) + + def __repr__(self): + return '<%s>:%s' % (self.__class__.__name__, dict( + itemsCount=self.itemsCount, + itemsCompleteCount=self.itemsCompleteCount, + itemsDownloadedCount=self.itemsDownloadedCount, + itemsReadyCount=self.itemsReadyCount, + itemsSuccessfulCount=self.itemsSuccessfulCount + )) + + +class MediaSettings(object): + """ Transcoding settings used for all media within :class:`~plexapi.sync.SyncItem`. + + Attributes: + audioBoost (int): unknown. + maxVideoBitrate (int|str): maximum bitrate for video, may be empty string. + musicBitrate (int|str): maximum bitrate for music, may be an empty string. + photoQuality (int): photo quality on scale 0 to 100. + photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`). + videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty). + subtitleSize (int): subtitle size on scale 0 to 100. + videoQuality (int): video quality on scale 0 to 100. + """ + + def __init__(self, maxVideoBitrate=4000, videoQuality=100, videoResolution='1280x720', audioBoost=100, + musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=100): + self.audioBoost = plexapi.utils.cast(int, audioBoost) + self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else '' + self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else '' + self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else '' + self.photoResolution = photoResolution + self.videoResolution = videoResolution + self.subtitleSize = plexapi.utils.cast(int, subtitleSize) if subtitleSize != '' else '' + self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else '' + + @staticmethod + def createVideo(videoQuality): + """ Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided video quality value. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in this module. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality. + """ + if videoQuality == VIDEO_QUALITY_ORIGINAL: + return MediaSettings('', '', '') + elif videoQuality < len(VIDEO_QUALITIES['bitrate']): + return MediaSettings(VIDEO_QUALITIES['bitrate'][videoQuality], + VIDEO_QUALITIES['videoQuality'][videoQuality], + VIDEO_QUALITIES['videoResolution'][videoQuality]) + else: + raise BadRequest('Unexpected video quality') + + @staticmethod + def createMusic(bitrate): + """ Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided music quality value + + Parameters: + bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the + module + """ + return MediaSettings(musicBitrate=bitrate) + + @staticmethod + def createPhoto(resolution): + """ Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided photo quality value. + + Parameters: + resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the + module. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality. + """ + if resolution in PHOTO_QUALITIES: + return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution) + else: + raise BadRequest('Unexpected photo quality') + + +class Policy(object): + """ Policy of syncing the media (how many items to sync and process watched media or not). + + Attributes: + scope (str): type of limitation policy, can be `count` or `all`. + value (int): amount of media to sync, valid only when `scope=count`. + unwatched (bool): True means disallow to sync watched media. + """ + + def __init__(self, scope, unwatched, value=0): + self.scope = scope + self.unwatched = plexapi.utils.cast(bool, unwatched) + self.value = plexapi.utils.cast(int, value) + + @staticmethod + def create(limit=None, unwatched=False): + """ Creates a :class:`~plexapi.sync.Policy` object for provided options and automatically sets proper `scope` + value. + + Parameters: + limit (int): limit items by count. + unwatched (bool): if True then watched items wouldn't be synced. + + Returns: + :class:`~plexapi.sync.Policy`. + """ + scope = 'all' + if limit is None: + limit = 0 + else: + scope = 'count' + + return Policy(scope, unwatched, limit) + + +VIDEO_QUALITIES = { + 'bitrate': [64, 96, 208, 320, 720, 1500, 2e3, 3e3, 4e3, 8e3, 1e4, 12e3, 2e4], + 'videoResolution': ['220x128', '220x128', '284x160', '420x240', '576x320', '720x480', '1280x720', '1280x720', + '1280x720', '1920x1080', '1920x1080', '1920x1080', '1920x1080'], + 'videoQuality': [10, 20, 30, 30, 40, 60, 60, 75, 100, 60, 75, 90, 100], +} + +VIDEO_QUALITY_0_2_MBPS = 2 +VIDEO_QUALITY_0_3_MBPS = 3 +VIDEO_QUALITY_0_7_MBPS = 4 +VIDEO_QUALITY_1_5_MBPS_480p = 5 +VIDEO_QUALITY_2_MBPS_720p = 6 +VIDEO_QUALITY_3_MBPS_720p = 7 +VIDEO_QUALITY_4_MBPS_720p = 8 +VIDEO_QUALITY_8_MBPS_1080p = 9 +VIDEO_QUALITY_10_MBPS_1080p = 10 +VIDEO_QUALITY_12_MBPS_1080p = 11 +VIDEO_QUALITY_20_MBPS_1080p = 12 +VIDEO_QUALITY_ORIGINAL = -1 + +AUDIO_BITRATE_96_KBPS = 96 +AUDIO_BITRATE_128_KBPS = 128 +AUDIO_BITRATE_192_KBPS = 192 +AUDIO_BITRATE_320_KBPS = 320 + +PHOTO_QUALITIES = { + '720x480': 24, + '1280x720': 49, + '1920x1080': 74, + '3840x2160': 99, +} + +PHOTO_QUALITY_HIGHEST = PHOTO_QUALITY_2160p = '3840x2160' +PHOTO_QUALITY_HIGH = PHOTO_QUALITY_1080p = '1920x1080' +PHOTO_QUALITY_MEDIUM = PHOTO_QUALITY_720p = '1280x720' +PHOTO_QUALITY_LOW = PHOTO_QUALITY_480p = '720x480' diff --git a/service.plexskipintro/plexapi/utils.py b/service.plexskipintro/plexapi/utils.py new file mode 100644 index 0000000000..9f2ac19ed2 --- /dev/null +++ b/service.plexskipintro/plexapi/utils.py @@ -0,0 +1,509 @@ +# -*- coding: utf-8 -*- +import base64 +import functools +import logging +import os +import re +import string +import time +import unicodedata +import warnings +import zipfile +from datetime import datetime +from getpass import getpass +from threading import Event, Thread +from urllib.parse import quote + +import requests +from plexapi.exceptions import BadRequest, NotFound + +try: + from tqdm import tqdm +except ImportError: + tqdm = None + +log = logging.getLogger('plexapi') + +# Search Types - Plex uses these to filter specific media types when searching. +# Library Types - Populated at runtime +SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7, + 'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14, + 'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'optimizedVersion': 42, 'userPlaylistItem': 1001} +PLEXOBJECTS = {} + + +class SecretsFilter(logging.Filter): + """ Logging filter to hide secrets. """ + + def __init__(self, secrets=None): + self.secrets = secrets or set() + + def add_secret(self, secret): + if secret is not None: + self.secrets.add(secret) + return secret + + def filter(self, record): + cleanargs = list(record.args) + for i in range(len(cleanargs)): + if isinstance(cleanargs[i], str): + for secret in self.secrets: + cleanargs[i] = cleanargs[i].replace(secret, '<hidden>') + record.args = tuple(cleanargs) + return True + + +def registerPlexObject(cls): + """ Registry of library types we may come across when parsing XML. This allows us to + define a few helper functions to dynamically convery the XML into objects. See + buildItem() below for an example. + """ + etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE)) + ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG + if ehash in PLEXOBJECTS: + raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' % + (cls.__name__, cls.TAG, etype, PLEXOBJECTS[ehash].__name__)) + PLEXOBJECTS[ehash] = cls + return cls + + +def cast(func, value): + """ Cast the specified value to the specified type (returned by func). Currently this + only support str, int, float, bool. Should be extended if needed. + + Parameters: + func (func): Calback function to used cast to type (int, bool, float). + value (any): value to be cast and returned. + """ + if value is not None: + if func == bool: + if value in (1, True, "1", "true"): + return True + elif value in (0, False, "0", "false"): + return False + else: + raise ValueError(value) + + elif func in (int, float): + try: + return func(value) + except ValueError: + return float('nan') + return func(value) + return value + + +def joinArgs(args): + """ Returns a query string (uses for HTTP URLs) where only the value is URL encoded. + Example return value: '?genre=action&type=1337'. + + Parameters: + args (dict): Arguments to include in query string. + """ + if not args: + return '' + arglist = [] + for key in sorted(args, key=lambda x: x.lower()): + value = str(args[key]) + arglist.append('%s=%s' % (key, quote(value, safe=''))) + return '?%s' % '&'.join(arglist) + + +def lowerFirst(s): + return s[0].lower() + s[1:] + + +def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover + """ Returns the value at the specified attrstr location within a nexted tree of + dicts, lists, tuples, functions, classes, etc. The lookup is done recursively + for each key in attrstr (split by by the delimiter) This function is heavily + influenced by the lookups used in Django templates. + + Parameters: + obj (any): Object to start the lookup in (dict, obj, list, tuple, etc). + attrstr (str): String to lookup (ex: 'foo.bar.baz.value') + default (any): Default value to return if not found. + delim (str): Delimiter separating keys in attrstr. + """ + try: + parts = attrstr.split(delim, 1) + attr = parts[0] + attrstr = parts[1] if len(parts) == 2 else None + if isinstance(obj, dict): + value = obj[attr] + elif isinstance(obj, list): + value = obj[int(attr)] + elif isinstance(obj, tuple): + value = obj[int(attr)] + elif isinstance(obj, object): + value = getattr(obj, attr) + if attrstr: + return rget(value, attrstr, default, delim) + return value + except: # noqa: E722 + return default + + +def searchType(libtype): + """ Returns the integer value of the library string type. + + Parameters: + libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track, + collection) + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown libtype + """ + libtype = str(libtype) + if libtype in [str(v) for v in list(SEARCHTYPES.values())]: + return libtype + if SEARCHTYPES.get(libtype) is not None: + return SEARCHTYPES[libtype] + raise NotFound('Unknown libtype: %s' % libtype) + + +def reverseSearchType(libtype): + """ Returns the string value of the library type. + + Parameters: + libtype (int): Integer value of the library type. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown libtype + """ + if libtype in SEARCHTYPES: + return libtype + libtype = int(libtype) + for k, v in list(SEARCHTYPES.items()): + if libtype == v: + return k + raise NotFound('Unknown libtype: %s' % libtype) + + +def threaded(callback, listargs): + """ Returns the result of <callback> for each set of `*args` in listargs. Each call + to <callback> is called concurrently in their own separate threads. + + Parameters: + callback (func): Callback function to apply to each set of `*args`. + listargs (list): List of lists; `*args` to pass each thread. + """ + threads, results = [], [] + job_is_done_event = Event() + for args in listargs: + args += [results, len(results)] + results.append(None) + threads.append(Thread(target=callback, args=args, kwargs=dict(job_is_done_event=job_is_done_event))) + threads[-1].setDaemon(True) + threads[-1].start() + while not job_is_done_event.is_set(): + if all(not t.is_alive() for t in threads): + break + time.sleep(0.05) + + return [r for r in results if r is not None] + + +def toDatetime(value, format=None): + """ Returns a datetime object from the specified value. + + Parameters: + value (str): value to return as a datetime + format (str): Format to pass strftime (optional; if value is a str). + """ + if value and value is not None: + if format: + try: + # value = datetime.strptime(value, format) + t=1 + except ValueError: + log.info('Failed to parse %s to datetime, defaulting to None', value) + return None + else: + # https://bugs.python.org/issue30684 + # And platform support for before epoch seems to be flaky. + # Also limit to max 32-bit integer + value = min(max(int(value), 86400), 2**31 - 1) + value = datetime.fromtimestamp(int(value)) + return value + + +def millisecondToHumanstr(milliseconds): + """ Returns human readable time duration from milliseconds. + HH:MM:SS:MMMM + + Parameters: + milliseconds (str,int): time duration in milliseconds. + """ + milliseconds = int(milliseconds) + r = datetime.utcfromtimestamp(milliseconds / 1000) + f = r.strftime("%H:%M:%S.%f") + return f[:-2] + + +def toList(value, itemcast=None, delim=','): + """ Returns a list of strings from the specified value. + + Parameters: + value (str): comma delimited string to convert to list. + itemcast (func): Function to cast each list item to (default str). + delim (str): string delimiter (optional; default ','). + """ + value = value or '' + itemcast = itemcast or str + return [itemcast(item) for item in value.split(delim) if item != ''] + + +def cleanFilename(filename, replace='_'): + whitelist = "-_.()[] {}{}".format(string.ascii_letters, string.digits) + cleaned_filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode() + cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename) + return cleaned_filename + + +def downloadSessionImages(server, filename=None, height=150, width=150, + opacity=100, saturation=100): # pragma: no cover + """ Helper to download a bif image or thumb.url from plex.server.sessions. + + Parameters: + filename (str): default to None, + height (int): Height of the image. + width (int): width of the image. + opacity (int): Opacity of the resulting image (possibly deprecated). + saturation (int): Saturating of the resulting image. + + Returns: + {'hellowlol': {'filepath': '<filepath>', 'url': 'http://<url>'}, + {'<username>': {filepath, url}}, ... + """ + info = {} + for media in server.sessions(): + url = None + for part in media.iterParts(): + if media.thumb: + url = media.thumb + if part.indexes: # always use bif images if available. + url = '/library/parts/%s/indexes/%s/%s' % (part.id, part.indexes.lower(), media.viewOffset) + if url: + if filename is None: + prettyname = media._prettyfilename() + filename = 'session_transcode_%s_%s_%s' % (media.usernames[0], prettyname, int(time.time())) + url = server.transcodeImage(url, height, width, opacity, saturation) + filepath = download(url, filename=filename) + info['username'] = {'filepath': filepath, 'url': url} + return info + + +def download(url, token, filename=None, savepath=None, session=None, chunksize=4024, + unpack=False, mocked=False, showstatus=False): + """ Helper to download a thumb, videofile or other media item. Returns the local + path to the downloaded file. + + Parameters: + url (str): URL where the content be reached. + token (str): Plex auth token to include in headers. + filename (str): Filename of the downloaded file, default None. + savepath (str): Defaults to current working dir. + chunksize (int): What chunksize read/write at the time. + mocked (bool): Helper to do evertything except write the file. + unpack (bool): Unpack the zip file. + showstatus(bool): Display a progressbar. + + Example: + >>> download(a_episode.getStreamURL(), a_episode.location) + /path/to/file + """ + # fetch the data to be saved + session = session or requests.Session() + headers = {'X-Plex-Token': token} + response = session.get(url, headers=headers, stream=True) + # make sure the savepath directory exists + savepath = savepath or os.getcwd() + os.makedirs(savepath, exist_ok=True) + + # try getting filename from header if not specified in arguments (used for logs, db) + if not filename and response.headers.get('Content-Disposition'): + filename = re.findall(r'filename=\"(.+)\"', response.headers.get('Content-Disposition')) + filename = filename[0] if filename[0] else None + + filename = os.path.basename(filename) + fullpath = os.path.join(savepath, filename) + # append file.ext from content-type if not already there + extension = os.path.splitext(fullpath)[-1] + if not extension: + contenttype = response.headers.get('content-type') + if contenttype and 'image' in contenttype: + fullpath += contenttype.split('/')[1] + + # check this is a mocked download (testing) + if mocked: + log.debug('Mocked download %s', fullpath) + return fullpath + + # save the file to disk + log.info('Downloading: %s', fullpath) + if showstatus and tqdm: # pragma: no cover + total = int(response.headers.get('content-length', 0)) + bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename) + + with open(fullpath, 'wb') as handle: + for chunk in response.iter_content(chunk_size=chunksize): + handle.write(chunk) + if showstatus and tqdm: + bar.update(len(chunk)) + + if showstatus and tqdm: # pragma: no cover + bar.close() + # check we want to unzip the contents + if fullpath.endswith('zip') and unpack: + with zipfile.ZipFile(fullpath, 'r') as handle: + handle.extractall(savepath) + + return fullpath + + +def tag_singular(tag): + if tag == 'countries': + return 'country' + elif tag == 'similar': + return 'similar' + else: + return tag[:-1] + + +def tag_plural(tag): + if tag == 'country': + return 'countries' + elif tag == 'similar': + return 'similar' + else: + return tag + 's' + + +def tag_helper(tag, items, locked=True, remove=False): + """ Simple tag helper for editing a object. """ + if not isinstance(items, list): + items = [items] + data = {} + if not remove: + for i, item in enumerate(items): + tagname = '%s[%s].tag.tag' % (tag, i) + data[tagname] = item + if remove: + tagname = '%s[].tag.tag-' % tag + data[tagname] = ','.join(items) + data['%s.locked' % tag] = 1 if locked else 0 + return data + + +def getMyPlexAccount(opts=None): # pragma: no cover + """ Helper function tries to get a MyPlex Account instance by checking + the the following locations for a username and password. This is + useful to create user-friendly command line tools. + 1. command-line options (opts). + 2. environment variables and config.ini + 3. Prompt on the command line. + """ + from plexapi import CONFIG + from plexapi.myplex import MyPlexAccount + # 1. Check command-line options + if opts and opts.username and opts.password: + print('Authenticating with Plex.tv as %s..' % opts.username) + return MyPlexAccount(opts.username, opts.password) + # 2. Check Plexconfig (environment variables and config.ini) + config_username = CONFIG.get('auth.myplex_username') + config_password = CONFIG.get('auth.myplex_password') + if config_username and config_password: + print('Authenticating with Plex.tv as %s..' % config_username) + return MyPlexAccount(config_username, config_password) + config_token = CONFIG.get('auth.server_token') + if config_token: + print('Authenticating with Plex.tv with token') + return MyPlexAccount(token=config_token) + # 3. Prompt for username and password on the command line + username = input('What is your plex.tv username: ') + password = getpass('What is your plex.tv password: ') + print('Authenticating with Plex.tv as %s..' % username) + return MyPlexAccount(username, password) + + +def createMyPlexDevice(headers, account, timeout=10): # pragma: no cover + """ Helper function to create a new MyPlexDevice. + + Parameters: + headers (dict): Provide the X-Plex- headers for the new device. + A unique X-Plex-Client-Identifier is required. + account (MyPlexAccount): The Plex account to create the device on. + timeout (int): Timeout in seconds to wait for device login. + """ + from plexapi.myplex import MyPlexPinLogin + + if 'X-Plex-Client-Identifier' not in headers: + raise BadRequest('The X-Plex-Client-Identifier header is required.') + + clientIdentifier = headers['X-Plex-Client-Identifier'] + + pinlogin = MyPlexPinLogin(headers=headers) + pinlogin.run(timeout=timeout) + account.link(pinlogin.pin) + pinlogin.waitForLogin() + + return account.device(clientId=clientIdentifier) + + +def choose(msg, items, attr): # pragma: no cover + """ Command line helper to display a list of choices, asking the + user to choose one of the options. + """ + # Return the first item if there is only one choice + if len(items) == 1: + return items[0] + # Print all choices to the command line + print() + for index, i in enumerate(items): + name = attr(i) if callable(attr) else getattr(i, attr) + print(' %s: %s' % (index, name)) + print() + # Request choice from the user + while True: + try: + inp = input('%s: ' % msg) + if any(s in inp for s in (':', '::', '-')): + idx = slice(*[int(x.strip()) if x.strip() else None for x in inp.split(':')]) + return items[idx] + else: + return items[int(inp)] + + except (ValueError, IndexError): + pass + + +def getAgentIdentifier(section, agent): + """ Return the full agent identifier from a short identifier, name, or confirm full identifier. """ + agents = [] + for ag in section.agents(): + identifiers = [ag.identifier, ag.shortIdentifier, ag.name] + if agent in identifiers: + return ag.identifier + agents += identifiers + raise NotFound('Couldnt find "%s" in agents list (%s)' % + (agent, ', '.join(agents))) + + +def base64str(text): + return base64.b64encode(text.encode('utf-8')).decode('utf-8') + + +def deprecated(message, stacklevel=2): + def decorator(func): + """This is a decorator which can be used to mark functions + as deprecated. It will result in a warning being emitted + when the function is used.""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message) + warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel) + log.warning(msg) + return func(*args, **kwargs) + return wrapper + return decorator diff --git a/service.plexskipintro/plexapi/video.py b/service.plexskipintro/plexapi/video.py new file mode 100644 index 0000000000..4049d6c532 --- /dev/null +++ b/service.plexskipintro/plexapi/video.py @@ -0,0 +1,949 @@ +# -*- coding: utf-8 -*- +import os +from urllib.parse import quote_plus, urlencode + +from plexapi import library, media, utils +from plexapi.base import Playable, PlexPartialObject +from plexapi.exceptions import BadRequest +from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin +from plexapi.mixins import RatingMixin, SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin + + +class Video(PlexPartialObject): + """ Base class for all video objects including :class:`~plexapi.video.Movie`, + :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`, + :class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`. + + Attributes: + addedAt (datetime): Datetime the item was added to the library. + art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>). + artBlurHash (str): BlurHash string for artwork image. + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the movie, show, season, episode, or clip (plex://movie/5d776b59ad5437001f79c6f8). + key (str): API URL (/library/metadata/<ratingkey>). + lastRatedAt (datetime): Datetime the item was last rated. + lastViewedAt (datetime): Datetime the item was last played. + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + listType (str): Hardcoded as 'video' (useful for search filters). + ratingKey (int): Unique key identifying the item. + summary (str): Summary of the movie, show, season, episode, or clip. + thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>). + thumbBlurHash (str): BlurHash string for thumbnail image. + title (str): Name of the movie, show, season, episode, or clip. + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'movie', 'show', 'season', 'episode', or 'clip'. + updatedAt (datatime): Datetime the item was updated. + userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars). + viewCount (int): Count of times the item was played. + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.art = data.attrib.get('art') + self.artBlurHash = data.attrib.get('artBlurHash') + self.fields = self.findItems(data, media.Field) + self.guid = data.attrib.get('guid') + self.key = data.attrib.get('key', '') + self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) + self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.listType = 'video' + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.summary = data.attrib.get('summary') + self.thumb = data.attrib.get('thumb') + self.thumbBlurHash = data.attrib.get('thumbBlurHash') + self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort', self.title) + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.userRating = utils.cast(float, data.attrib.get('userRating')) + self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) + + @property + def isWatched(self): + """ Returns True if this video is watched. """ + return bool(self.viewCount > 0) if self.viewCount else False + + def url(self, part): + """ Returns the full url for something. Typically used for getting a specific image. """ + return self._server.url(part, includeToken=True) if part else None + + def markWatched(self): + """ Mark the video as palyed. """ + key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey + self._server.query(key) + + def markUnwatched(self): + """ Mark the video as unplayed. """ + key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey + self._server.query(key) + + def augmentation(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. + Augmentation returns hub items relating to online media sources + such as Tidal Music "Track from {item}" or "Soundtrack of {item}". + Plex Pass and linked Tidal account are required. + """ + account = self._server.myPlexAccount() + tidalOptOut = next( + (service.value for service in account.onlineMediaSources() + if service.key == 'tv.plex.provider.music'), + None + ) + if account.subscriptionStatus != 'Active' or tidalOptOut == 'opt_out': + raise BadRequest('Requires Plex Pass and Tidal Music enabled.') + data = self._server.query(self.key + '?asyncAugmentMetadata=1') + augmentationKey = data.attrib.get('augmentationKey') + return self.fetchItems(augmentationKey) + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return self.title + + def subtitleStreams(self): + """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """ + streams = [] + + parts = self.iterParts() + for part in parts: + streams += part.subtitleStreams() + return streams + + def uploadSubtitles(self, filepath): + """ Upload Subtitle file for video. """ + url = '%s/subtitles' % self.key + filename = os.path.basename(filepath) + subFormat = os.path.splitext(filepath)[1][1:] + with open(filepath, 'rb') as subfile: + params = {'title': filename, + 'format': subFormat + } + headers = {'Accept': 'text/plain, */*'} + self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers) + + def removeSubtitles(self, streamID=None, streamTitle=None): + """ Remove Subtitle from movie's subtitles listing. + + Note: If subtitle file is located inside video directory it will bbe deleted. + Files outside of video directory are not effected. + """ + for stream in self.subtitleStreams(): + if streamID == stream.id or streamTitle == stream.title: + self._server.query(stream.key, self._server._session.delete) + + def optimize(self, title=None, target="", targetTagID=None, locationID=-1, policyScope='all', + policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None): + """ Optimize item + + locationID (int): -1 in folder with original items + 2 library path id + library path id is found in library.locations[i].id + + target (str): custom quality name. + if none provided use "Custom: {deviceProfile}" + + targetTagID (int): Default quality settings + 1 Mobile + 2 TV + 3 Original Quality + + deviceProfile (str): Android, IOS, Universal TV, Universal Mobile, Windows Phone, + Windows, Xbox One + + Example: + Optimize for Mobile + item.optimize(targetTagID="Mobile") or item.optimize(targetTagID=1") + Optimize for Android 10 MBPS 1080p + item.optimize(deviceProfile="Android", videoQuality=10) + Optimize for IOS Original Quality + item.optimize(deviceProfile="IOS", videoQuality=-1) + + * see sync.py VIDEO_QUALITIES for additional information for using videoQuality + """ + tagValues = [1, 2, 3] + tagKeys = ["Mobile", "TV", "Original Quality"] + tagIDs = tagKeys + tagValues + + if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None): + raise BadRequest('Unexpected or missing quality profile.') + + libraryLocationIDs = [location.id for location in self.section()._locations()] + libraryLocationIDs.append(-1) + + if locationID not in libraryLocationIDs: + raise BadRequest('Unexpected library path ID. %s not in %s' % + (locationID, libraryLocationIDs)) + + if isinstance(targetTagID, str): + tagIndex = tagKeys.index(targetTagID) + targetTagID = tagValues[tagIndex] + + if title is None: + title = self.title + + backgroundProcessing = self.fetchItem('/playlists?type=42') + key = '%s/items?' % backgroundProcessing.key + params = { + 'Item[type]': 42, + 'Item[target]': target, + 'Item[targetTagID]': targetTagID if targetTagID else '', + 'Item[locationID]': locationID, + 'Item[Policy][scope]': policyScope, + 'Item[Policy][value]': policyValue, + 'Item[Policy][unwatched]': policyUnwatched + } + + if deviceProfile: + params['Item[Device][profile]'] = deviceProfile + + if videoQuality: + from plexapi.sync import MediaSettings + mediaSettings = MediaSettings.createVideo(videoQuality) + params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality + params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution + params['Item[MediaSettings][maxVideoBitrate]'] = mediaSettings.maxVideoBitrate + params['Item[MediaSettings][audioBoost]'] = '' + params['Item[MediaSettings][subtitleSize]'] = '' + params['Item[MediaSettings][musicBitrate]'] = '' + params['Item[MediaSettings][photoQuality]'] = '' + + titleParam = {'Item[title]': title} + section = self._server.library.sectionByID(self.librarySectionID) + params['Item[Location][uri]'] = 'library://' + section.uuid + '/item/' + \ + quote_plus(self.key + '?includeExternalMedia=1') + + data = key + urlencode(params) + '&' + urlencode(titleParam) + return self._server.query(data, method=self._server._session.put) + + def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None): + """ Add current video (movie, tv-show, season or episode) as sync item for specified device. + See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`~plexapi.sync` module. + client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`~plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`. + limit (int): maximum count of items to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current media. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + """ + + from plexapi.sync import SyncItem, Policy, MediaSettings + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self._defaultSyncTitle() + sync_item.rootTitle = self.title + sync_item.contentType = self.listType + sync_item.metadataType = self.METADATA_TYPE + sync_item.machineIdentifier = self._server.machineIdentifier + + section = self._server.library.sectionByID(self.librarySectionID) + + sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key)) + sync_item.policy = Policy.create(limit, unwatched) + sync_item.mediaSettings = MediaSettings.createVideo(videoQuality) + + return myplex.sync(sync_item, client=client, clientId=clientId) + + +@utils.registerPlexObject +class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin, + CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin): + """ Represents a single Movie. + + Attributes: + TAG (str): 'Video' + TYPE (str): 'movie' + audienceRating (float): Audience rating (usually from Rotten Tomatoes). + audienceRatingImage (str): Key to audience rating image (rottentomatoes://image.rating.spilled). + chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects. + chapterSource (str): Chapter source (agent; media; mixed). + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + contentRating (str) Content rating (PG-13; NR; TV-G). + countries (List<:class:`~plexapi.media.Country`>): List of countries objects. + directors (List<:class:`~plexapi.media.Director`>): List of director objects. + duration (int): Duration of the movie in milliseconds. + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + languageOverride (str): Setting that indicates if a languge is used to override metadata + (eg. en-CA, None = Library default). + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the movie was released. + originalTitle (str): Original title, often the foreign title (転々; 엽기적인 그녀). + primaryExtraKey (str) Primary extra key (/library/metadata/66351). + producers (List<:class:`~plexapi.media.Producer`>): List of producers objects. + rating (float): Movie critic rating (7.9; 9.8; 8.1). + ratingImage (str): Key to critic rating image (rottentomatoes://image.rating.rotten). + roles (List<:class:`~plexapi.media.Role`>): List of role objects. + similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. + studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment). + tagline (str): Movie tag line (Back 2 Work; Who says men can't change?). + useOriginalTitle (int): Setting that indicates if the original title is used for the movie + (-1 = Library default, 0 = No, 1 = Yes). + viewOffset (int): View offset in milliseconds. + writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. + year (int): Year movie was released. + """ + TAG = 'Video' + TYPE = 'movie' + METADATA_TYPE = 'movie' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + Playable._loadData(self, data) + self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) + self.audienceRatingImage = data.attrib.get('audienceRatingImage') + self.chapters = self.findItems(data, media.Chapter) + self.chapterSource = data.attrib.get('chapterSource') + self.collections = self.findItems(data, media.Collection) + self.contentRating = data.attrib.get('contentRating') + self.countries = self.findItems(data, media.Country) + self.directors = self.findItems(data, media.Director) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.genres = self.findItems(data, media.Genre) + self.guids = self.findItems(data, media.Guid) + self.labels = self.findItems(data, media.Label) + self.languageOverride = data.attrib.get('languageOverride') + self.media = self.findItems(data, media.Media) + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.originalTitle = data.attrib.get('originalTitle') + self.primaryExtraKey = data.attrib.get('primaryExtraKey') + self.producers = self.findItems(data, media.Producer) + self.rating = utils.cast(float, data.attrib.get('rating')) + self.ratingImage = data.attrib.get('ratingImage') + self.roles = self.findItems(data, media.Role) + self.similar = self.findItems(data, media.Similar) + self.studio = data.attrib.get('studio') + self.tagline = data.attrib.get('tagline') + self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) + self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) + self.writers = self.findItems(data, media.Writer) + self.year = utils.cast(int, data.attrib.get('year')) + + @property + def actors(self): + """ Alias to self.roles. """ + return self.roles + + @property + def locations(self): + """ This does not exist in plex xml response but is added to have a common + interface to get the locations of the movie. + + Returns: + List<str> of file paths where the movie is found on disk. + """ + return [part.file for part in self.iterParts() if part] + + @property + def hasPreviewThumbnails(self): + """ Returns True if any of the media parts has generated preview (BIF) thumbnails. """ + return any(part.hasPreviewThumbnails for media in self.media for part in media.parts) + + def _prettyfilename(self): + """ Returns a filename for use in download. """ + return '%s (%s)' % (self.title, self.year) + + def reviews(self): + """ Returns a list of :class:`~plexapi.media.Review` objects. """ + data = self._server.query(self._details_key) + return self.findItems(data, media.Review, rtag='Video') + + def extras(self): + """ Returns a list of :class:`~plexapi.video.Extra` objects. """ + data = self._server.query(self._details_key) + return self.findItems(data, Extra, rtag='Extras') + + def hubs(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. """ + data = self._server.query(self._details_key) + return self.findItems(data, library.Hub, rtag='Related') + + +@utils.registerPlexObject +class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin, + CollectionMixin, GenreMixin, LabelMixin): + """ Represents a single Show (including all seasons and episodes). + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'show' + audienceRating (float): Audience rating (TMDB or TVDB). + audienceRatingImage (str): Key to audience rating image (tmdb://image.rating). + autoDeletionItemPolicyUnwatchedLibrary (int): Setting that indicates the number of unplayed + episodes to keep for the show (0 = All episodes, 5 = 5 latest episodes, 3 = 3 latest episodes, + 1 = 1 latest episode, -3 = Episodes added in the past 3 days, -7 = Episodes added in the + past 7 days, -30 = Episodes added in the past 30 days). + autoDeletionItemPolicyWatchedLibrary (int): Setting that indicates if episodes are deleted + after being watched for the show (0 = Never, 1 = After a day, 7 = After a week, + 100 = On next refresh). + banner (str): Key to banner artwork (/library/metadata/<ratingkey>/banner/<bannerid>). + childCount (int): Number of seasons in the show. + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + contentRating (str) Content rating (PG-13; NR; TV-G). + duration (int): Typical duration of the show episodes in milliseconds. + episodeSort (int): Setting that indicates how episodes are sorted for the show + (-1 = Library default, 0 = Oldest first, 1 = Newest first). + flattenSeasons (int): Setting that indicates if seasons are set to hidden for the show + (-1 = Library default, 0 = Hide, 1 = Show). + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. + index (int): Plex index number for the show. + key (str): API URL (/library/metadata/<ratingkey>). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + languageOverride (str): Setting that indicates if a languge is used to override metadata + (eg. en-CA, None = Library default). + leafCount (int): Number of items in the show view. + locations (List<str>): List of folder paths where the show is found on disk. + network (str): The network that distributed the show. + originallyAvailableAt (datetime): Datetime the show was released. + originalTitle (str): The original title of the show. + rating (float): Show rating (7.9; 9.8; 8.1). + roles (List<:class:`~plexapi.media.Role`>): List of role objects. + showOrdering (str): Setting that indicates the episode ordering for the show + (None = Library default). + similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. + studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment). + tagline (str): Show tag line. + theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>). + useOriginalTitle (int): Setting that indicates if the original title is used for the show + (-1 = Library default, 0 = No, 1 = Yes). + viewedLeafCount (int): Number of items marked as played in the show view. + year (int): Year the show was released. + """ + TAG = 'Directory' + TYPE = 'show' + METADATA_TYPE = 'episode' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) + self.audienceRatingImage = data.attrib.get('audienceRatingImage') + self.autoDeletionItemPolicyUnwatchedLibrary = utils.cast( + int, data.attrib.get('autoDeletionItemPolicyUnwatchedLibrary', '0')) + self.autoDeletionItemPolicyWatchedLibrary = utils.cast( + int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0')) + self.banner = data.attrib.get('banner') + self.childCount = utils.cast(int, data.attrib.get('childCount')) + self.collections = self.findItems(data, media.Collection) + self.contentRating = data.attrib.get('contentRating') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.episodeSort = utils.cast(int, data.attrib.get('episodeSort', '-1')) + self.flattenSeasons = utils.cast(int, data.attrib.get('flattenSeasons', '-1')) + self.genres = self.findItems(data, media.Genre) + self.guids = self.findItems(data, media.Guid) + self.index = utils.cast(int, data.attrib.get('index')) + self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.labels = self.findItems(data, media.Label) + self.languageOverride = data.attrib.get('languageOverride') + self.leafCount = utils.cast(int, data.attrib.get('leafCount')) + self.locations = self.listAttrs(data, 'path', etag='Location') + self.network = data.attrib.get('network') + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.originalTitle = data.attrib.get('originalTitle') + self.rating = utils.cast(float, data.attrib.get('rating')) + self.roles = self.findItems(data, media.Role) + self.showOrdering = data.attrib.get('showOrdering') + self.similar = self.findItems(data, media.Similar) + self.studio = data.attrib.get('studio') + self.tagline = data.attrib.get('tagline') + self.theme = data.attrib.get('theme') + self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) + self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) + self.year = utils.cast(int, data.attrib.get('year')) + + def __iter__(self): + for season in self.seasons(): + yield season + + @property + def actors(self): + """ Alias to self.roles. """ + return self.roles + + @property + def isWatched(self): + """ Returns True if the show is fully watched. """ + return bool(self.viewedLeafCount == self.leafCount) + + def hubs(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. """ + data = self._server.query(self._details_key) + return self.findItems(data, library.Hub, rtag='Related') + + def onDeck(self): + """ Returns show's On Deck :class:`~plexapi.video.Video` object or `None`. + If show is unwatched, return will likely be the first episode. + """ + data = self._server.query(self._details_key) + return next(iter(self.findItems(data, rtag='OnDeck')), None) + + def season(self, title=None, season=None): + """ Returns the season with the specified title or number. + + Parameters: + title (str): Title of the season to return. + season (int): Season number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. + """ + key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey + if title is not None and not isinstance(title, int): + return self.fetchItem(key, Season, title__iexact=title) + elif season is not None or isinstance(title, int): + if isinstance(title, int): + index = title + else: + index = season + return self.fetchItem(key, Season, index=index) + raise BadRequest('Missing argument: title or season is required') + + def seasons(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Season` objects in the show. """ + key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey + return self.fetchItems(key, Season, **kwargs) + + def episode(self, title=None, season=None, episode=None): + """ Find a episode using a title or season and episode. + + Parameters: + title (str): Title of the episode to return + season (int): Season number (default: None; required if title not specified). + episode (int): Episode number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing. + """ + key = '/library/metadata/%s/allLeaves' % self.ratingKey + if title is not None: + return self.fetchItem(key, Episode, title__iexact=title) + elif season is not None and episode is not None: + return self.fetchItem(key, Episode, parentIndex=season, index=episode) + raise BadRequest('Missing argument: title or season and episode are required') + + def episodes(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Episode` objects in the show. """ + key = '/library/metadata/%s/allLeaves' % self.ratingKey + return self.fetchItems(key, Episode, **kwargs) + + def get(self, title=None, season=None, episode=None): + """ Alias to :func:`~plexapi.video.Show.episode`. """ + return self.episode(title, season, episode) + + def watched(self): + """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(viewCount__gt=0) + + def unwatched(self): + """ Returns list of unwatched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(viewCount=0) + + def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs): + """ Download all episodes from the show. See :func:`~plexapi.base.Playable.download` for details. + + Parameters: + savepath (str): Defaults to current working dir. + keep_original_name (bool): True to keep the original filename otherwise + a friendlier filename is generated. + subfolders (bool): True to separate episodes in to season folders. + **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`. + """ + filepaths = [] + for episode in self.episodes(): + _savepath = os.path.join(savepath, 'Season %s' % str(episode.seasonNumber).zfill(2)) if subfolders else savepath + filepaths += episode.download(_savepath, keep_original_name, **kwargs) + return filepaths + + +@utils.registerPlexObject +class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin): + """ Represents a single Show Season (including all episodes). + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'season' + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. + index (int): Season number. + key (str): API URL (/library/metadata/<ratingkey>). + leafCount (int): Number of items in the season view. + parentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6). + parentIndex (int): Plex index number for the show. + parentKey (str): API URL of the show (/library/metadata/<parentRatingKey>). + parentRatingKey (int): Unique key identifying the show. + parentStudio (str): Studio that created show. + parentTheme (str): URL to show theme resource (/library/metadata/<parentRatingkey>/theme/<themeid>). + parentThumb (str): URL to show thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>). + parentTitle (str): Name of the show for the season. + viewedLeafCount (int): Number of items marked as played in the season view. + year (int): Year the season was released. + """ + TAG = 'Directory' + TYPE = 'season' + METADATA_TYPE = 'episode' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + self.collections = self.findItems(data, media.Collection) + self.guids = self.findItems(data, media.Guid) + self.index = utils.cast(int, data.attrib.get('index')) + self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.leafCount = utils.cast(int, data.attrib.get('leafCount')) + self.parentGuid = data.attrib.get('parentGuid') + self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) + self.parentKey = data.attrib.get('parentKey') + self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentStudio = data.attrib.get('parentStudio') + self.parentTheme = data.attrib.get('parentTheme') + self.parentThumb = data.attrib.get('parentThumb') + self.parentTitle = data.attrib.get('parentTitle') + self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) + self.year = utils.cast(int, data.attrib.get('year')) + + def __iter__(self): + for episode in self.episodes(): + yield episode + + def __repr__(self): + return '<%s>' % ':'.join([p for p in [ + self.__class__.__name__, + self.key.replace('/library/metadata/', '').replace('/children', ''), + '%s-s%s' % (self.parentTitle.replace(' ', '-')[:20], self.seasonNumber), + ] if p]) + + @property + def isWatched(self): + """ Returns True if the season is fully watched. """ + return bool(self.viewedLeafCount == self.leafCount) + + @property + def seasonNumber(self): + """ Returns the season number. """ + return self.index + + def episodes(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Episode` objects in the season. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, Episode, **kwargs) + + def episode(self, title=None, episode=None): + """ Returns the episode with the given title or number. + + Parameters: + title (str): Title of the episode to return. + episode (int): Episode number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing. + """ + key = '/library/metadata/%s/children' % self.ratingKey + if title is not None and not isinstance(title, int): + return self.fetchItem(key, Episode, title__iexact=title) + elif episode is not None or isinstance(title, int): + if isinstance(title, int): + index = title + else: + index = episode + return self.fetchItem(key, Episode, parentIndex=self.index, index=index) + raise BadRequest('Missing argument: title or episode is required') + + def get(self, title=None, episode=None): + """ Alias to :func:`~plexapi.video.Season.episode`. """ + return self.episode(title, episode) + + def onDeck(self): + """ Returns season's On Deck :class:`~plexapi.video.Video` object or `None`. + Will only return a match if the show's On Deck episode is in this season. + """ + data = self._server.query(self._details_key) + return next(iter(self.findItems(data, rtag='OnDeck')), None) + + def show(self): + """ Return the season's :class:`~plexapi.video.Show`. """ + return self.fetchItem(self.parentRatingKey) + + def watched(self): + """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(viewCount__gt=0) + + def unwatched(self): + """ Returns list of unwatched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(viewCount=0) + + def download(self, savepath=None, keep_original_name=False, **kwargs): + """ Download all episodes from the season. See :func:`~plexapi.base.Playable.download` for details. + + Parameters: + savepath (str): Defaults to current working dir. + keep_original_name (bool): True to keep the original filename otherwise + a friendlier filename is generated. + **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`. + """ + filepaths = [] + for episode in self.episodes(): + filepaths += episode.download(savepath, keep_original_name, **kwargs) + return filepaths + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return '%s - %s' % (self.parentTitle, self.title) + + +@utils.registerPlexObject +class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin, + CollectionMixin, DirectorMixin, WriterMixin): + """ Represents a single Shows Episode. + + Attributes: + TAG (str): 'Video' + TYPE (str): 'episode' + audienceRating (float): Audience rating (TMDB or TVDB). + audienceRatingImage (str): Key to audience rating image (tmdb://image.rating). + chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects. + chapterSource (str): Chapter source (agent; media; mixed). + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + contentRating (str) Content rating (PG-13; NR; TV-G). + directors (List<:class:`~plexapi.media.Director`>): List of director objects. + duration (int): Duration of the episode in milliseconds. + grandparentArt (str): URL to show artwork (/library/metadata/<grandparentRatingKey>/art/<artid>). + grandparentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6). + grandparentKey (str): API URL of the show (/library/metadata/<grandparentRatingKey>). + grandparentRatingKey (int): Unique key identifying the show. + grandparentTheme (str): URL to show theme resource (/library/metadata/<grandparentRatingkey>/theme/<themeid>). + grandparentThumb (str): URL to show thumbnail image (/library/metadata/<grandparentRatingKey>/thumb/<thumbid>). + grandparentTitle (str): Name of the show for the episode. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. + index (int): Episode number. + markers (List<:class:`~plexapi.media.Marker`>): List of marker objects. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the episode was released. + parentGuid (str): Plex GUID for the season (plex://season/5d9c09e42df347001e3c2a72). + parentIndex (int): Season number of episode. + parentKey (str): API URL of the season (/library/metadata/<parentRatingKey>). + parentRatingKey (int): Unique key identifying the season. + parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>). + parentTitle (str): Name of the season for the episode. + parentYear (int): Year the season was released. + producers (List<:class:`~plexapi.media.Producer`>): List of producers objects. + rating (float): Episode rating (7.9; 9.8; 8.1). + roles (List<:class:`~plexapi.media.Role`>): List of role objects. + skipParent (bool): True if the show's seasons are set to hidden. + viewOffset (int): View offset in milliseconds. + writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. + year (int): Year the episode was released. + """ + TAG = 'Video' + TYPE = 'episode' + METADATA_TYPE = 'episode' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + Playable._loadData(self, data) + self._seasonNumber = None # cached season number + self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) + self.audienceRatingImage = data.attrib.get('audienceRatingImage') + self.chapters = self.findItems(data, media.Chapter) + self.chapterSource = data.attrib.get('chapterSource') + self.collections = self.findItems(data, media.Collection) + self.contentRating = data.attrib.get('contentRating') + self.directors = self.findItems(data, media.Director) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.grandparentArt = data.attrib.get('grandparentArt') + self.grandparentGuid = data.attrib.get('grandparentGuid') + self.grandparentKey = data.attrib.get('grandparentKey') + self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) + self.grandparentTheme = data.attrib.get('grandparentTheme') + self.grandparentThumb = data.attrib.get('grandparentThumb') + self.grandparentTitle = data.attrib.get('grandparentTitle') + self.guids = self.findItems(data, media.Guid) + self.index = utils.cast(int, data.attrib.get('index')) + self.markers = self.findItems(data, media.Marker) + self.media = self.findItems(data, media.Media) + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.parentGuid = data.attrib.get('parentGuid') + self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) + self.parentKey = data.attrib.get('parentKey') + self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentThumb = data.attrib.get('parentThumb') + self.parentTitle = data.attrib.get('parentTitle') + self.parentYear = utils.cast(int, data.attrib.get('parentYear')) + self.producers = self.findItems(data, media.Producer) + self.rating = utils.cast(float, data.attrib.get('rating')) + self.roles = self.findItems(data, media.Role) + self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) + self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) + self.writers = self.findItems(data, media.Writer) + self.year = utils.cast(int, data.attrib.get('year')) + + # If seasons are hidden, parentKey and parentRatingKey are missing from the XML response. + # https://forums.plex.tv/t/parentratingkey-not-in-episode-xml-when-seasons-are-hidden/300553 + if self.skipParent and not self.parentRatingKey: + # Parse the parentRatingKey from the parentThumb + if self.parentThumb and self.parentThumb.startswith('/library/metadata/'): + self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3]) + # Get the parentRatingKey from the season's ratingKey + if not self.parentRatingKey and self.grandparentRatingKey: + self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey + if self.parentRatingKey: + self.parentKey = '/library/metadata/%s' % self.parentRatingKey + + def __repr__(self): + return '<%s>' % ':'.join([p for p in [ + self.__class__.__name__, + self.key.replace('/library/metadata/', '').replace('/children', ''), + '%s-%s' % (self.grandparentTitle.replace(' ', '-')[:20], self.seasonEpisode), + ] if p]) + + def _prettyfilename(self): + """ Returns a filename for use in download. """ + return '%s - %s - %s' % (self.grandparentTitle, self.seasonEpisode, self.title) + + @property + def actors(self): + """ Alias to self.roles. """ + return self.roles + + @property + def locations(self): + """ This does not exist in plex xml response but is added to have a common + interface to get the locations of the episode. + + Returns: + List<str> of file paths where the episode is found on disk. + """ + return [part.file for part in self.iterParts() if part] + + @property + def episodeNumber(self): + """ Returns the episode number. """ + return self.index + + @property + def seasonNumber(self): + """ Returns the episode's season number. """ + if self._seasonNumber is None: + self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber + return utils.cast(int, self._seasonNumber) + + @property + def seasonEpisode(self): + """ Returns the s00e00 string containing the season and episode numbers. """ + return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.episodeNumber).zfill(2)) + + @property + def hasCommercialMarker(self): + """ Returns True if the episode has a commercial marker in the xml. """ + return any(marker.type == 'commercial' for marker in self.markers) + + @property + def hasIntroMarker(self): + """ Returns True if the episode has an intro marker in the xml. """ + return any(marker.type == 'intro' for marker in self.markers) + + @property + def hasPreviewThumbnails(self): + """ Returns True if any of the media parts has generated preview (BIF) thumbnails. """ + return any(part.hasPreviewThumbnails for media in self.media for part in media.parts) + + def season(self): + """" Return the episode's :class:`~plexapi.video.Season`. """ + return self.fetchItem(self.parentKey) + + def show(self): + """" Return the episode's :class:`~plexapi.video.Show`. """ + return self.fetchItem(self.grandparentRatingKey) + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title) + + +@utils.registerPlexObject +class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin): + """ Represents a single Clip. + + Attributes: + TAG (str): 'Video' + TYPE (str): 'clip' + duration (int): Duration of the clip in milliseconds. + extraType (int): Unknown. + index (int): Plex index number for the clip. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the clip was released. + skipDetails (int): Unknown. + subtype (str): Type of clip (trailer, behindTheScenes, sceneOrSample, etc.). + thumbAspectRatio (str): Aspect ratio of the thumbnail image. + viewOffset (int): View offset in milliseconds. + year (int): Year clip was released. + """ + TAG = 'Video' + TYPE = 'clip' + METADATA_TYPE = 'clip' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + Playable._loadData(self, data) + self._data = data + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.extraType = utils.cast(int, data.attrib.get('extraType')) + self.index = utils.cast(int, data.attrib.get('index')) + self.media = self.findItems(data, media.Media) + self.originallyAvailableAt = utils.toDatetime( + data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.skipDetails = utils.cast(int, data.attrib.get('skipDetails')) + self.subtype = data.attrib.get('subtype') + self.thumbAspectRatio = data.attrib.get('thumbAspectRatio') + self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) + self.year = utils.cast(int, data.attrib.get('year')) + + @property + def locations(self): + """ This does not exist in plex xml response but is added to have a common + interface to get the locations of the clip. + + Returns: + List<str> of file paths where the clip is found on disk. + """ + return [part.file for part in self.iterParts() if part] + + def _prettyfilename(self): + """ Returns a filename for use in download. """ + return self.title + + +class Extra(Clip): + """ Represents a single Extra (trailer, behindTheScenes, etc). """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(Extra, self)._loadData(data) + parent = self._parent() + self.librarySectionID = parent.librarySectionID + self.librarySectionKey = parent.librarySectionKey + self.librarySectionTitle = parent.librarySectionTitle + + def _prettyfilename(self): + """ Returns a filename for use in download. """ + return '%s (%s)' % (self.title, self.subtype) diff --git a/service.plexskipintro/resources/fanart.jpg b/service.plexskipintro/resources/fanart.jpg new file mode 100644 index 0000000000..211168f32f Binary files /dev/null and b/service.plexskipintro/resources/fanart.jpg differ diff --git a/service.plexskipintro/resources/icon.png b/service.plexskipintro/resources/icon.png new file mode 100644 index 0000000000..38b6b037e3 Binary files /dev/null and b/service.plexskipintro/resources/icon.png differ diff --git a/service.plexskipintro/resources/media/icon_array.png b/service.plexskipintro/resources/media/icon_array.png new file mode 100644 index 0000000000..f2603a8b1c Binary files /dev/null and b/service.plexskipintro/resources/media/icon_array.png differ diff --git a/service.plexskipintro/resources/media/icon_date.png b/service.plexskipintro/resources/media/icon_date.png new file mode 100644 index 0000000000..c99bd98b15 Binary files /dev/null and b/service.plexskipintro/resources/media/icon_date.png differ diff --git a/service.plexskipintro/resources/media/icon_float.png b/service.plexskipintro/resources/media/icon_float.png new file mode 100644 index 0000000000..4531d5398b Binary files /dev/null and b/service.plexskipintro/resources/media/icon_float.png differ diff --git a/service.plexskipintro/resources/media/icon_integer.png b/service.plexskipintro/resources/media/icon_integer.png new file mode 100644 index 0000000000..bca3808dd8 Binary files /dev/null and b/service.plexskipintro/resources/media/icon_integer.png differ diff --git a/service.plexskipintro/resources/media/icon_string.png b/service.plexskipintro/resources/media/icon_string.png new file mode 100644 index 0000000000..25fa751998 Binary files /dev/null and b/service.plexskipintro/resources/media/icon_string.png differ diff --git a/service.plexskipintro/resources/media/plexskipintroSS.png b/service.plexskipintro/resources/media/plexskipintroSS.png new file mode 100644 index 0000000000..a4e5ee6e8f Binary files /dev/null and b/service.plexskipintro/resources/media/plexskipintroSS.png differ diff --git a/service.plexskipintro/resources/media/plexskipintroTokenSS.png b/service.plexskipintro/resources/media/plexskipintroTokenSS.png new file mode 100644 index 0000000000..dcad7df591 Binary files /dev/null and b/service.plexskipintro/resources/media/plexskipintroTokenSS.png differ diff --git a/service.plexskipintro/resources/settings.xml b/service.plexskipintro/resources/settings.xml new file mode 100644 index 0000000000..3615cf7cf0 --- /dev/null +++ b/service.plexskipintro/resources/settings.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8" standalone="yes"?> +<settings> + <category label="32001"> + <setting label="Plex Base Url" type="text" id="plex_base_url" default="http://192.168.0.100:32400"/> + <setting label="Authentication Token" type="text" id="auth_token" default="" /> + <setting label="Timeout" type="number" id="default_timeout" default="10"/> + </category> +</settings> \ No newline at end of file diff --git a/service.plexskipintro/resources/skins/Default/720p/script-dialog.xml b/service.plexskipintro/resources/skins/Default/720p/script-dialog.xml new file mode 100644 index 0000000000..fd99d02a25 --- /dev/null +++ b/service.plexskipintro/resources/skins/Default/720p/script-dialog.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<window id="9999"> + <defaultcontrol always="true">9001</defaultcontrol> + <animation effect="fade" start="0" end="100" time="300">WindowOpen</animation> + <animation effect="fade" start="100" end="0" time="300">WindowClose</animation> + <controls> + <control type="grouplist" id="9001"> + <posx>25</posx> + <posy>293</posy> + <width>800</width> + <height>150</height> + <align>left</align> + <itemgap>15</itemgap> + <orientation>vertical</orientation> + <control type="button" id="201"> + <description>Later Button</description> + <width>200</width> + <height>40</height> + <align>left</align> + <aligny>center</aligny> + <label>Skip Intro</label> + <font>font12_title</font> + <textcolor>FFFFFFFF</textcolor> + <texturenofocus>button-nofo.png</texturenofocus> + <texturefocus>button-fo.png</texturefocus> + <textoffsetx>10</textoffsetx> + </control> + </control> + </controls> +</window> diff --git a/service.plexskipintro/resources/skins/Default/media/bg-fade.png b/service.plexskipintro/resources/skins/Default/media/bg-fade.png new file mode 100644 index 0000000000..636678df84 Binary files /dev/null and b/service.plexskipintro/resources/skins/Default/media/bg-fade.png differ diff --git a/service.plexskipintro/resources/skins/Default/media/button-fo.png b/service.plexskipintro/resources/skins/Default/media/button-fo.png new file mode 100644 index 0000000000..f0cfa3d5f0 Binary files /dev/null and b/service.plexskipintro/resources/skins/Default/media/button-fo.png differ diff --git a/service.plexskipintro/resources/skins/Default/media/button-nofo.png b/service.plexskipintro/resources/skins/Default/media/button-nofo.png new file mode 100644 index 0000000000..04853195c3 Binary files /dev/null and b/service.plexskipintro/resources/skins/Default/media/button-nofo.png differ diff --git a/service.plexskipintro/service.py b/service.plexskipintro/service.py new file mode 100644 index 0000000000..e78d91ed54 --- /dev/null +++ b/service.plexskipintro/service.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from lib.addon import * +class Service(xbmc.Monitor): + + def __init__(self, *args): + addonName = 'Plex TV Skip' + + + def onNotification(self, sender, method, data): + if method in ["Player.OnSeek"]: + onSeek() + if method in ["Player.OnPlay"]: + onPlay() + def ServiceEntryPoint(self): + monitor() + +Service().ServiceEntryPoint() \ No newline at end of file diff --git a/service.plexskipintro/targets.cfg b/service.plexskipintro/targets.cfg new file mode 100644 index 0000000000..c2b8ae4f84 --- /dev/null +++ b/service.plexskipintro/targets.cfg @@ -0,0 +1,95 @@ +; This is the config file used by server to generate add-on repositories. +; +; Each section contains the name of the target repository, a list of branches +; to fetch from, and list of dependency requirements. Add-ons not meeting +; these requirements will be skipped. +; +; Changes to this file will be synced server-side. E.g. removing a target here +; will delete that target from the server. + +; Dharma legacy repo +[dharma] +branches=dharma + +; Eden legacy repo +[eden] +branches=eden + +; Frodo legacy repo +[frodo] +branches=frodo + +; Gotham legacy repo +[gotham] +branches=gotham +minversions = + xbmc.gui:5.0.0, + xbmc.python:2.1.0, + xbmc.json:6.0.0, + xbmc.metadata:1.0, + xbmc.addon:12.0.0 + +; Helix legacy repo +[helix] +branches=gotham,helix +minversions = + xbmc.gui:5.3.0, + xbmc.python:2.1.0, + xbmc.json:6.0.0, + xbmc.metadata:1.0, + xbmc.addon:12.0.0 + +; Isengard legacy repo +[isengard] +branches=gotham,helix,isengard +minversions = + xbmc.gui:5.3.0, + xbmc.python:2.1.0, + xbmc.json:6.0.0, + xbmc.metadata:1.0, + xbmc.addon:12.0.0 + +[jarvis] +branches = gotham,helix,isengard,jarvis +minversions = + xbmc.gui:5.10.0, + xbmc.python:2.1.0, + xbmc.json:6.0.0, + xbmc.metadata:1.0, + xbmc.addon:12.0.0 + +[krypton] +branches = gotham,helix,isengard,jarvis,krypton +minversions = + xbmc.gui:5.12.0, + xbmc.python:2.1.0, + xbmc.json:6.0.0, + xbmc.metadata:1.0, + xbmc.addon:12.0.0 + +[leia] +branches = gotham,helix,isengard,jarvis,krypton,leia +minversions = + xbmc.gui:5.14.0, + xbmc.python:2.1.0, + xbmc.json:6.0.0, + xbmc.metadata:1.0, + xbmc.addon:12.0.0 + +[matrix] +branches = gotham,helix,isengard,jarvis,krypton,leia,matrix +minversions = + xbmc.gui:5.15.0, + xbmc.python:3.0.0, + xbmc.json:6.0.0, + xbmc.metadata:1.0, + xbmc.addon:12.0.0 + +[nexus] +branches = gotham,helix,isengard,jarvis,krypton,leia,matrix,nexus +minversions = + xbmc.gui:5.15.0, + xbmc.python:3.0.0, + xbmc.json:6.0.0, + xbmc.metadata:1.0, + xbmc.addon:12.0.0