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
+
+[](#)
+## 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)
+
+[](#)
+
+## 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: `` ()``
+ * Episode: `` - s00e00 - ``
+ * Track: `` - - 00 - ``
+ * Photo: `` - `` or ````
+ """
+ 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/).
+ 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): 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 '/'.
+ 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''`
+ 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//art/).
+ 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/).
+ 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//thumb/).
+ 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 if not found.
+
+ Parameters:
+ key (str): Configuration variable to load in the format '.'.
+ 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): 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= 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): 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//all?``).
+ """
+ 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 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//all?).
+ librarySectionID (int): The library section ID where the tag is found.
+ librarySectionKey (str): API URL for the library section (/library/section/)
+ 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 = (' '
+ % (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 = (' '
+ % (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//all?genre=)
+ 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/)
+ 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/)
+ 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).
+
+ : 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 (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.
+
+ : 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/).
+ 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//all?).
+ 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/).
+ 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): 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) Lit of account roles. Plexpass membership listed here.
+ scrobbleTypes (str): Description
+ secure (bool): Description
+ subscriptionActive (bool): True if your subsctiption is active.
+ subscriptionFeatures: (List) 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.)): 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.)): 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//art/).
+ composite (str): URL to composite image (/library/metadata//composite/)
+ 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/).
+ 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//thumb/).
+ 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/).
+ 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 (int): Unique key identifying the photo album.
+ parentThumb (str): URL to photo album thumbnail image (/library/metadata//thumb/).
+ 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//thumb/).
+ 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 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//composite/)
+ 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/).
+ 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 `_ 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 `_ 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 `_ 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 `_ version.
+ sync (bool): True if `syncing 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 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 `_
+ 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/)
+ 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/)
+ 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): 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, '')
+ 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 for each set of `*args` in listargs. Each call
+ to 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': '', 'url': 'http://'},
+ {'': {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//art/).
+ 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/).
+ 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//thumb/).
+ 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 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//banner/).
+ 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/).
+ 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): 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//theme/).
+ 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/).
+ 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 (int): Unique key identifying the show.
+ parentStudio (str): Studio that created show.
+ parentTheme (str): URL to show theme resource (/library/metadata//theme/).
+ parentThumb (str): URL to show thumbnail image (/library/metadata//thumb/).
+ 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//art/).
+ grandparentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6).
+ grandparentKey (str): API URL of the show (/library/metadata/).
+ grandparentRatingKey (int): Unique key identifying the show.
+ grandparentTheme (str): URL to show theme resource (/library/metadata//theme/).
+ grandparentThumb (str): URL to show thumbnail image (/library/metadata//thumb/).
+ 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 (int): Unique key identifying the season.
+ parentThumb (str): URL to season thumbnail image (/library/metadata//thumb/).
+ 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 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 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 @@
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+ 9001
+ WindowOpen
+ WindowClose
+
+
+ 25
+ 293
+ 800
+ 150
+ left
+ 15
+ vertical
+
+ Later Button
+ 200
+ 40
+ left
+ center
+ Skip Intro
+ font12_title
+ FFFFFFFF
+ button-nofo.png
+ button-fo.png
+ 10
+
+
+
+
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