diff --git a/README.md b/README.md index 546f860..bf9cd58 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ https://github.com/gordielachance/plugin.audio.subsonic ## Installation * Navigate to your `.kodi/addons/` folder * Clone this repository: `git clone https://github.com/gordielachance/plugin.audio.subsonic.git` -* Install the SimplePlugin dependency by cloning its repository: `git clone https://github.com/romanvm/script.module.simpleplugin.git` * (Re)start Kodi. ## TODO diff --git a/addon.xml b/addon.xml index 01c1710..2910039 100644 --- a/addon.xml +++ b/addon.xml @@ -3,7 +3,6 @@ - audio diff --git a/lib/py-sonic/.gitignore b/lib/py-sonic/.gitignore deleted file mode 100644 index 977ac6e..0000000 --- a/lib/py-sonic/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -*.pyc -test.py -MANIFEST -dist -build diff --git a/lib/py-sonic/CHANGELOG.md b/lib/py-sonic/CHANGELOG.md deleted file mode 100644 index a9c7e59..0000000 --- a/lib/py-sonic/CHANGELOG.md +++ /dev/null @@ -1,57 +0,0 @@ -## 0.6.2 - -* Added an option to use GET requests, instead of the default POST requests - -## 0.6.1 - -* Added `legacyAuth` option for pre-1.13.0 support - -## 0.6.0 - -* Added API 1.14.0 support - -## 0.5.1 - -* Added the ability to use a netrc file for credentials - -## 0.5.0 - -* Added support for using credentials via a netrc file - -## 0.4.1 - -* Fixed SSL handling issues - -## 0.4.0 - -* Added missing 1.12.0 API items -* Added 1.13.0 API items -* All timestamps both passed in, and returned, should now be in **proper** unix time, which is seconds since the epoch, **not** milliseconds since the epoch - -## 0.3.5 - -* allow for self-signed certs - -## 0.3.4 - -* Add missing parameters to getAlbumList2 (thanks to basilfx) -* Remove trailing whitespace (thanks to basilfx) - -## 0.3.3 - -* Added support for API version 1.11.0 -* Added a couple of additions from API version 1.10.x that were previously - missed - -## 0.3.1 - -* Incorporated unofficial API calls (beallio) - -## 0.2.1 - -* Added a patch to force SSLv3 as some users were apparently having issues - with the 4.7 release of Subsonic and SSL. (thanks to orangepeelbeef) - -## 0.2.0 - -* Added support for API version 1.8.0 (Subsonic verion 4.7) diff --git a/lib/py-sonic/LICENSE b/lib/py-sonic/LICENSE deleted file mode 100644 index 94a9ed0..0000000 --- a/lib/py-sonic/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - 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/lib/py-sonic/MANIFEST.in b/lib/py-sonic/MANIFEST.in deleted file mode 100644 index 7f21ea1..0000000 --- a/lib/py-sonic/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include LICENSE -include CHANGELOG.md -include README.md diff --git a/lib/py-sonic/README.md b/lib/py-sonic/README.md deleted file mode 100644 index 42b3a0f..0000000 --- a/lib/py-sonic/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# py-sonic # - -## INSTALL ## - -Installation is fairly simple. Just do the standard install as root: - - tar -xvzf py-sonic-*.tar.gz - cd py-sonic-* - python setup.py install - -You can also install directly using *pip* or *easy_install* - - pip install py-sonic - -## PYTHON 3 ## - -I've added experimental support for Python 3. This is not fully tested, but I -do encourage people to fork the repository and test off the "python3" branch -and issue pull requests. If you don't want to go through that trouble, bug -reports are always welcome too! - -## USAGE ## - -This library follows the REST API almost exactly (for now). If you follow the -documentation on http://www.subsonic.org/pages/api.jsp or you do a: - - pydoc libsonic.connection - -I have also added documentation at http://stuffivelearned.org/doku.php?id=programming:python:py-sonic - -## BASIC TUTORIAL ## - -This is about as basic as it gets. We are just going to set up the connection -and then get a couple of random songs. - -```python -#!/usr/bin/env python - -from pprint import pprint -import libsonic - -# We pass in the base url, the username, password, and port number -# Be sure to use https:// if this is an ssl connection! -conn = libsonic.Connection('https://music.example.com' , 'myuser' , - 'secretpass' , port=443) -# Let's get 2 completely random songs -songs = conn.getRandomSongs(size=2) -# We'll just pretty print the results we got to the terminal -pprint(songs) -``` - -As you can see, it's really pretty simple. If you use the documentation -provided in the library: - - pydoc libsonic.connection - -or the api docs on subsonic.org (listed above), you should be able to make use -of your server without too much trouble. - -Right now, only plain old dictionary structures are returned. The plan -for a later release includes the following: - -* Proper object representations for Artist, Album, Song, etc. -* Lazy access of members (the song objects aren't created until you want to - do something with them) - -## TODO ## - -In the future, I would like to make this a little more "pythonic" and add -some classes to wrap up the data returned from the server. Right now, the data -is just returned in the form of a dict, but I would like to have actual -Song, Album, Folder, etc. classes instead, or at least an alternative. For -now, it works. - -*NOTE:* I've noticed a wart with the upstream Subsonic API wherein any -of the Connection methods that would normally return a list of dictionary -elements (getPlaylists() for example), will only return a dictionary if there -is a single return element. I plan on changing this in py-sonic so that -any methods of that nature will *always* return a list, even if there is -only a single dict in the list. diff --git a/lib/py-sonic/libsonic/__init__.py b/lib/py-sonic/libsonic/__init__.py deleted file mode 100644 index 7832d82..0000000 --- a/lib/py-sonic/libsonic/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -This file is part of py-sonic. - -py-sonic 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. - -py-sonic 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 py-sonic. If not, see - -For information on method calls, see 'pydoc libsonic.connection' - ----------- -Basic example: ----------- - -import libsonic - -conn = libsonic.Connection('http://localhost' , 'admin' , 'password') -print conn.ping() - -""" - -from connection import * - -__version__ = '0.6.2' diff --git a/lib/py-sonic/libsonic/connection.py b/lib/py-sonic/libsonic/connection.py deleted file mode 100644 index ec116ef..0000000 --- a/lib/py-sonic/libsonic/connection.py +++ /dev/null @@ -1,2770 +0,0 @@ -""" -This file is part of py-sonic. - -py-sonic 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. - -py-sonic 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 py-sonic. If not, see -""" - -from urllib import urlencode -from .errors import * -from pprint import pprint -from cStringIO import StringIO -from netrc import netrc -from hashlib import md5 -import json, urllib2, httplib, logging, socket, ssl, sys, os - -API_VERSION = '1.14.0' - -logger = logging.getLogger(__name__) - -class HTTPSConnectionChain(httplib.HTTPSConnection): - _preferred_ssl_protos = sorted([ p for p in dir(ssl) - if p.startswith('PROTOCOL_') ], reverse=True) - _ssl_working_proto = None - - def _create_sock(self): - sock = socket.create_connection((self.host, self.port), self.timeout) - if self._tunnel_host: - self.sock = sock - self._tunnel() - return sock - - def connect(self): - if self._ssl_working_proto is not None: - # If we have a working proto, let's use that straight away - logger.debug("Using known working proto: '%s'", - self._ssl_working_proto) - sock = self._create_sock() - self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, - ssl_version=self._ssl_working_proto) - return - - # Try connecting via the different SSL protos in preference order - for proto_name in self._preferred_ssl_protos: - sock = self._create_sock() - proto = getattr(ssl, proto_name, None) - try: - self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, - ssl_version=proto) - except: - sock.close() - else: - # Cache the working ssl version - HTTPSConnectionChain._ssl_working_proto = proto - break - - -class HTTPSHandlerChain(urllib2.HTTPSHandler): - def https_open(self, req): - return self.do_open(HTTPSConnectionChain, req) - -# install opener -urllib2.install_opener(urllib2.build_opener(HTTPSHandlerChain())) - -class PysHTTPRedirectHandler(urllib2.HTTPRedirectHandler): - """ - This class is used to override the default behavior of the - HTTPRedirectHandler, which does *not* redirect POST data - """ - def redirect_request(self, req, fp, code, msg, headers, newurl): - m = req.get_method() - if (code in (301, 302, 303, 307) and m in ("GET", "HEAD") - or code in (301, 302, 303) and m == "POST"): - newurl = newurl.replace(' ', '%20') - newheaders = dict((k, v) for k, v in req.headers.items() - if k.lower() not in ("content-length", "content-type") - ) - data = None - if req.has_data(): - data = req.get_data() - return urllib2.Request(newurl, - data=data, - headers=newheaders, - origin_req_host=req.get_origin_req_host(), - unverifiable=True) - else: - raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp) - -class Connection(object): - def __init__(self, baseUrl, username=None, password=None, port=4040, - serverPath='/rest', appName='py-sonic', apiVersion=API_VERSION, - insecure=False, useNetrc=None, legacyAuth=False, useGET=False): - """ - This will create a connection to your subsonic server - - baseUrl:str The base url for your server. Be sure to use - "https" for SSL connections. If you are using - a port other than the default 4040, be sure to - specify that with the port argument. Do *not* - append it here. - - ex: http://subsonic.example.com - - If you are running subsonic under a different - path, specify that with the "serverPath" arg, - *not* here. For example, if your subsonic - lives at: - - https://mydomain.com:8080/path/to/subsonic/rest - - You would set the following: - - baseUrl = "https://mydomain.com" - port = 8080 - serverPath = "/path/to/subsonic/rest" - username:str The username to use for the connection. This - can be None if `useNetrc' is True (and you - have a valid entry in your netrc file) - password:str The password to use for the connection. This - can be None if `useNetrc' is True (and you - have a valid entry in your netrc file) - port:int The port number to connect on. The default for - unencrypted subsonic connections is 4040 - serverPath:str The base resource path for the subsonic views. - This is useful if you have your subsonic server - behind a proxy and the path that you are proxying - is different from the default of '/rest'. - Ex: - serverPath='/path/to/subs' - - The full url that would be built then would be - (assuming defaults and using "example.com" and - you are using the "ping" view): - - http://example.com:4040/path/to/subs/ping.view - appName:str The name of your application. - apiVersion:str The API version you wish to use for your - application. Subsonic will throw an error if you - try to use/send an api version higher than what - the server supports. See the Subsonic API docs - to find the Subsonic version -> API version table. - This is useful if you are connecting to an older - version of Subsonic. - insecure:bool This will allow you to use self signed - certificates when connecting if set to True. - useNetrc:str|bool You can either specify a specific netrc - formatted file or True to use your default - netrc file ($HOME/.netrc). - legacyAuth:bool Use pre-1.13.0 API version authentication - useGET:bool Use a GET request instead of the default POST - request. This is not recommended as request - URLs can get very long with some API calls - """ - self._baseUrl = baseUrl - self._hostname = baseUrl.split('://')[1].strip() - self._username = username - self._rawPass = password - self._legacyAuth = legacyAuth - self._useGET = useGET - - self._netrc = None - if useNetrc is not None: - self._process_netrc(useNetrc) - elif username is None or password is None: - raise CredentialError('You must specify either a username/password ' - 'combination or "useNetrc" must be either True or a string ' - 'representing a path to a netrc file') - - self._port = int(port) - self._apiVersion = apiVersion - self._appName = appName - self._serverPath = serverPath.strip('/') - self._insecure = insecure - self._opener = self._getOpener(self._username, self._rawPass) - - # Properties - def setBaseUrl(self, url): - self._baseUrl = url - self._opener = self._getOpener(self._username, self._rawPass) - baseUrl = property(lambda s: s._baseUrl, setBaseUrl) - - def setPort(self, port): - self._port = int(port) - port = property(lambda s: s._port, setPort) - - def setUsername(self, username): - self._username = username - self._opener = self._getOpener(self._username, self._rawPass) - username = property(lambda s: s._username, setUsername) - - def setPassword(self, password): - self._rawPass = password - # Redo the opener with the new creds - self._opener = self._getOpener(self._username, self._rawPass) - password = property(lambda s: s._rawPass, setPassword) - - apiVersion = property(lambda s: s._apiVersion) - - def setAppName(self, appName): - self._appName = appName - appName = property(lambda s: s._appName, setAppName) - - def setServerPath(self, path): - self._serverPath = path.strip('/') - serverPath = property(lambda s: s._serverPath, setServerPath) - - def setInsecure(self, insecure): - self._insecure = insecure - insecure = property(lambda s: s._insecure, setInsecure) - - def setLegacyAuth(self, lauth): - self._legacyAuth = lauth - legacyAuth = property(lambda s: s._legacyAuth, setLegacyAuth) - - def setGET(self, get): - self._useGET = get - useGET = property(lambda s: s._useGET, setGET) - - # API methods - def ping(self): - """ - since: 1.0.0 - - Returns a boolean True if the server is alive, False otherwise - """ - methodName = 'ping' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - try: - res = self._doInfoReq(req) - except: - return False - if res['status'] == 'ok': - return True - elif res['status'] == 'failed': - exc = getExcByCode(res['error']['code']) - raise exc(res['error']['message']) - return False - - def getLicense(self): - """ - since: 1.0.0 - - Gets details related to the software license - - Returns a dict like the following: - - {u'license': {u'date': u'2010-05-21T11:14:39', - u'email': u'email@example.com', - u'key': u'12345678901234567890123456789012', - u'valid': True}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getLicense' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getMusicFolders(self): - """ - since: 1.0.0 - - Returns all configured music folders - - Returns a dict like the following: - - {u'musicFolders': {u'musicFolder': [{u'id': 0, u'name': u'folder1'}, - {u'id': 1, u'name': u'folder2'}, - {u'id': 2, u'name': u'folder3'}]}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getMusicFolders' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getNowPlaying(self): - """ - since: 1.0.0 - - Returns what is currently being played by all users - - Returns a dict like the following: - - {u'nowPlaying': {u'entry': {u'album': u"Jazz 'Round Midnight 12", - u'artist': u'Astrud Gilberto', - u'bitRate': 172, - u'contentType': u'audio/mpeg', - u'coverArt': u'98349284', - u'duration': 325, - u'genre': u'Jazz', - u'id': u'2424324', - u'isDir': False, - u'isVideo': False, - u'minutesAgo': 0, - u'parent': u'542352', - u'path': u"Astrud Gilberto/Jazz 'Round Midnight 12/01 - The Girl From Ipanema.mp3", - u'playerId': 1, - u'size': 7004089, - u'suffix': u'mp3', - u'title': u'The Girl From Ipanema', - u'track': 1, - u'username': u'user1', - u'year': 1996}}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getNowPlaying' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getIndexes(self, musicFolderId=None, ifModifiedSince=0): - """ - since: 1.0.0 - - Returns an indexed structure of all artists - - musicFolderId:int If this is specified, it will only return - artists for the given folder ID from - the getMusicFolders call - ifModifiedSince:int If specified, return a result if the artist - collection has changed since the given - unix timestamp - - Returns a dict like the following: - - {u'indexes': {u'index': [{u'artist': [{u'id': u'29834728934', - u'name': u'A Perfect Circle'}, - {u'id': u'238472893', - u'name': u'A Small Good Thing'}, - {u'id': u'9327842983', - u'name': u'A Tribe Called Quest'}, - {u'id': u'29348729874', - u'name': u'A-Teens, The'}, - {u'id': u'298472938', - u'name': u'ABA STRUCTURE'}], - u'lastModified': 1303318347000L}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getIndexes' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'musicFolderId': musicFolderId, - 'ifModifiedSince': self._ts2milli(ifModifiedSince)}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - self._fixLastModified(res) - return res - - def getMusicDirectory(self, mid): - """ - since: 1.0.0 - - Returns a listing of all files in a music directory. Typically used - to get a list of albums for an artist or list of songs for an album. - - mid:str The string ID value which uniquely identifies the - folder. Obtained via calls to getIndexes or - getMusicDirectory. REQUIRED - - Returns a dict like the following: - - {u'directory': {u'child': [{u'artist': u'A Tribe Called Quest', - u'coverArt': u'223484', - u'id': u'329084', - u'isDir': True, - u'parent': u'234823940', - u'title': u'Beats, Rhymes And Life'}, - {u'artist': u'A Tribe Called Quest', - u'coverArt': u'234823794', - u'id': u'238472893', - u'isDir': True, - u'parent': u'2308472938', - u'title': u'Midnight Marauders'}, - {u'artist': u'A Tribe Called Quest', - u'coverArt': u'39284792374', - u'id': u'983274892', - u'isDir': True, - u'parent': u'9823749', - u'title': u"People's Instinctive Travels And The Paths Of Rhythm"}, - {u'artist': u'A Tribe Called Quest', - u'coverArt': u'289347293', - u'id': u'3894723934', - u'isDir': True, - u'parent': u'9832942', - u'title': u'The Anthology'}, - {u'artist': u'A Tribe Called Quest', - u'coverArt': u'923847923', - u'id': u'29834729', - u'isDir': True, - u'parent': u'2934872893', - u'title': u'The Love Movement'}, - {u'artist': u'A Tribe Called Quest', - u'coverArt': u'9238742893', - u'id': u'238947293', - u'isDir': True, - u'parent': u'9432878492', - u'title': u'The Low End Theory'}], - u'id': u'329847293', - u'name': u'A Tribe Called Quest'}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getMusicDirectory' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName, {'id': mid}) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def search(self, artist=None, album=None, title=None, any=None, - count=20, offset=0, newerThan=None): - """ - since: 1.0.0 - - DEPRECATED SINCE API 1.4.0! USE search2() INSTEAD! - - Returns a listing of files matching the given search criteria. - Supports paging with offset - - artist:str Search for artist - album:str Search for album - title:str Search for title of song - any:str Search all fields - count:int Max number of results to return [default: 20] - offset:int Search result offset. For paging [default: 0] - newerThan:int Return matches newer than this timestamp - """ - if artist == album == title == any == None: - raise ArgumentError('Invalid search. You must supply search ' - 'criteria') - methodName = 'search' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'artist': artist, 'album': album, - 'title': title, 'any': any, 'count': count, 'offset': offset, - 'newerThan': self._ts2milli(newerThan)}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def search2(self, query, artistCount=20, artistOffset=0, albumCount=20, - albumOffset=0, songCount=20, songOffset=0, musicFolderId=None): - """ - since: 1.4.0 - - Returns albums, artists and songs matching the given search criteria. - Supports paging through the result. - - query:str The search query - artistCount:int Max number of artists to return [default: 20] - artistOffset:int Search offset for artists (for paging) [default: 0] - albumCount:int Max number of albums to return [default: 20] - albumOffset:int Search offset for albums (for paging) [default: 0] - songCount:int Max number of songs to return [default: 20] - songOffset:int Search offset for songs (for paging) [default: 0] - musicFolderId:int Only return results from the music folder - with the given ID. See getMusicFolders - - Returns a dict like the following: - - {u'searchResult2': {u'album': [{u'artist': u'A Tribe Called Quest', - u'coverArt': u'289347', - u'id': u'32487298', - u'isDir': True, - u'parent': u'98374289', - u'title': u'The Love Movement'}], - u'artist': [{u'id': u'2947839', - u'name': u'A Tribe Called Quest'}, - {u'id': u'239847239', - u'name': u'Tribe'}], - u'song': [{u'album': u'Beats, Rhymes And Life', - u'artist': u'A Tribe Called Quest', - u'bitRate': 224, - u'contentType': u'audio/mpeg', - u'coverArt': u'329847', - u'duration': 148, - u'genre': u'default', - u'id': u'3928472893', - u'isDir': False, - u'isVideo': False, - u'parent': u'23984728394', - u'path': u'A Tribe Called Quest/Beats, Rhymes And Life/A Tribe Called Quest - Beats, Rhymes And Life - 03 - Motivators.mp3', - u'size': 4171913, - u'suffix': u'mp3', - u'title': u'Motivators', - u'track': 3}]}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'search2' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'query': query, 'artistCount': artistCount, - 'artistOffset': artistOffset, 'albumCount': albumCount, - 'albumOffset': albumOffset, 'songCount': songCount, - 'songOffset': songOffset, 'musicFolderId': musicFolderId}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def search3(self, query, artistCount=20, artistOffset=0, albumCount=20, - albumOffset=0, songCount=20, songOffset=0, musicFolderId=None): - """ - since: 1.8.0 - - Works the same way as search2, but uses ID3 tags for - organization - - query:str The search query - artistCount:int Max number of artists to return [default: 20] - artistOffset:int Search offset for artists (for paging) [default: 0] - albumCount:int Max number of albums to return [default: 20] - albumOffset:int Search offset for albums (for paging) [default: 0] - songCount:int Max number of songs to return [default: 20] - songOffset:int Search offset for songs (for paging) [default: 0] - musicFolderId:int Only return results from the music folder - with the given ID. See getMusicFolders - - Returns a dict like the following (search for "Tune Yards": - {u'searchResult3': {u'album': [{u'artist': u'Tune-Yards', - u'artistId': 1, - u'coverArt': u'al-7', - u'created': u'2012-01-30T12:35:33', - u'duration': 3229, - u'id': 7, - u'name': u'Bird-Brains', - u'songCount': 13}, - {u'artist': u'Tune-Yards', - u'artistId': 1, - u'coverArt': u'al-8', - u'created': u'2011-03-22T15:08:00', - u'duration': 2531, - u'id': 8, - u'name': u'W H O K I L L', - u'songCount': 10}], - u'artist': {u'albumCount': 2, - u'coverArt': u'ar-1', - u'id': 1, - u'name': u'Tune-Yards'}, - u'song': [{u'album': u'Bird-Brains', - u'albumId': 7, - u'artist': u'Tune-Yards', - u'artistId': 1, - u'bitRate': 160, - u'contentType': u'audio/mpeg', - u'coverArt': 105, - u'created': u'2012-01-30T12:35:33', - u'duration': 328, - u'genre': u'Lo-Fi', - u'id': 107, - u'isDir': False, - u'isVideo': False, - u'parent': 105, - u'path': u'Tune Yards/Bird-Brains/10-tune-yards-fiya.mp3', - u'size': 6588498, - u'suffix': u'mp3', - u'title': u'Fiya', - u'track': 10, - u'type': u'music', - u'year': 2009}]}, - - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'search3' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'query': query, 'artistCount': artistCount, - 'artistOffset': artistOffset, 'albumCount': albumCount, - 'albumOffset': albumOffset, 'songCount': songCount, - 'songOffset': songOffset, 'musicFolderId': musicFolderId}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getPlaylists(self, username=None): - """ - since: 1.0.0 - - Returns the ID and name of all saved playlists - The "username" option was added in 1.8.0. - - username:str If specified, return playlists for this user - rather than for the authenticated user. The - authenticated user must have admin role - if this parameter is used - - Returns a dict like the following: - - {u'playlists': {u'playlist': [{u'id': u'62656174732e6d3375', - u'name': u'beats'}, - {u'id': u'766172696574792e6d3375', - u'name': u'variety'}]}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getPlaylists' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'username': username}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getPlaylist(self, pid): - """ - since: 1.0.0 - - Returns a listing of files in a saved playlist - - id:str The ID of the playlist as returned in getPlaylists() - - Returns a dict like the following: - - {u'playlist': {u'entry': {u'album': u'The Essential Bob Dylan', - u'artist': u'Bob Dylan', - u'bitRate': 32, - u'contentType': u'audio/mpeg', - u'coverArt': u'2983478293', - u'duration': 984, - u'genre': u'Classic Rock', - u'id': u'982739428', - u'isDir': False, - u'isVideo': False, - u'parent': u'98327428974', - u'path': u"Bob Dylan/Essential Bob Dylan Disc 1/Bob Dylan - The Essential Bob Dylan - 03 - The Times They Are A-Changin'.mp3", - u'size': 3921899, - u'suffix': u'mp3', - u'title': u"The Times They Are A-Changin'", - u'track': 3}, - u'id': u'44796c616e2e6d3375', - u'name': u'Dylan'}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getPlaylist' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName, {'id': pid}) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def createPlaylist(self, playlistId=None, name=None, songIds=[]): - """ - since: 1.2.0 - - Creates OR updates a playlist. If updating the list, the - playlistId is required. If creating a list, the name is required. - - playlistId:str The ID of the playlist to UPDATE - name:str The name of the playlist to CREATE - songIds:list The list of songIds to populate the list with in - either create or update mode. Note that this - list will replace the existing list if updating - - Returns a dict like the following: - - {u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'createPlaylist' - viewName = '%s.view' % methodName - - if playlistId == name == None: - raise ArgumentError('You must supply either a playlistId or a name') - if playlistId is not None and name is not None: - raise ArgumentError('You can only supply either a playlistId ' - 'OR a name, not both') - - q = self._getQueryDict({'playlistId': playlistId, 'name': name}) - - req = self._getRequestWithList(viewName, 'songId', songIds, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def deletePlaylist(self, pid): - """ - since: 1.2.0 - - Deletes a saved playlist - - pid:str ID of the playlist to delete, as obtained by getPlaylists - - Returns a dict like the following: - - """ - methodName = 'deletePlaylist' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName, {'id': pid}) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def download(self, sid): - """ - since: 1.0.0 - - Downloads a given music file. - - sid:str The ID of the music file to download. - - Returns the file-like object for reading or raises an exception - on error - """ - methodName = 'download' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName, {'id': sid}) - res = self._doBinReq(req) - if isinstance(res, dict): - self._checkStatus(res) - return res - - def stream(self, sid, maxBitRate=0, tformat=None, timeOffset=None, - size=None, estimateContentLength=False, converted=False): - """ - since: 1.0.0 - - Downloads a given music file. - - sid:str The ID of the music file to download. - maxBitRate:int (since: 1.2.0) If specified, the server will - attempt to limit the bitrate to this value, in - kilobits per second. If set to zero (default), no limit - is imposed. Legal values are: 0, 32, 40, 48, 56, 64, - 80, 96, 112, 128, 160, 192, 224, 256 and 320. - tformat:str (since: 1.6.0) Specifies the target format - (e.g. "mp3" or "flv") in case there are multiple - applicable transcodings (since: 1.9.0) You can use - the special value "raw" to disable transcoding - timeOffset:int (since: 1.6.0) Only applicable to video - streaming. Start the stream at the given - offset (in seconds) into the video - size:str (since: 1.6.0) The requested video size in - WxH, for instance 640x480 - estimateContentLength:bool (since: 1.8.0) If set to True, - the HTTP Content-Length header - will be set to an estimated - value for trancoded media - converted:bool (since: 1.14.0) Only applicable to video streaming. - Subsonic can optimize videos for streaming by - converting them to MP4. If a conversion exists for - the video in question, then setting this parameter - to "true" will cause the converted video to be - returned instead of the original. - - Returns the file-like object for reading or raises an exception - on error - """ - methodName = 'stream' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'id': sid, 'maxBitRate': maxBitRate, - 'format': tformat, 'timeOffset': timeOffset, 'size': size, - 'estimateContentLength': estimateContentLength, - 'converted': converted}) - - req = self._getRequest(viewName, q) - res = self._doBinReq(req) - if isinstance(res, dict): - self._checkStatus(res) - return res - - def getCoverArt(self, aid, size=None): - """ - since: 1.0.0 - - Returns a cover art image - - aid:str ID string for the cover art image to download - size:int If specified, scale image to this size - - Returns the file-like object for reading or raises an exception - on error - """ - methodName = 'getCoverArt' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'id': aid, 'size': size}) - - req = self._getRequest(viewName, q) - res = self._doBinReq(req) - if isinstance(res, dict): - self._checkStatus(res) - return res - - def scrobble(self, sid, submission=True, listenTime=None): - """ - since: 1.5.0 - - "Scrobbles" a given music file on last.fm. Requires that the user - has set this up. - - Since 1.8.0 you may specify multiple id (and optionally time) - parameters to scrobble multiple files. - - Since 1.11.0 this method will also update the play count and - last played timestamp for the song and album. It will also make - the song appear in the "Now playing" page in the web app, and - appear in the list of songs returned by getNowPlaying - - sid:str The ID of the file to scrobble - submission:bool Whether this is a "submission" or a "now playing" - notification - listenTime:int (Since 1.8.0) The time (unix timestamp) at - which the song was listened to. - - Returns a dict like the following: - - {u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'scrobble' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'id': sid, 'submission': submission, - 'time': self._ts2milli(listenTime)}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def changePassword(self, username, password): - """ - since: 1.1.0 - - Changes the password of an existing Subsonic user. Note that the - user performing this must have admin privileges - - username:str The username whose password is being changed - password:str The new password of the user - - Returns a dict like the following: - - {u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'changePassword' - viewName = '%s.view' % methodName - hexPass = 'enc:%s' % self._hexEnc(password) - - # There seems to be an issue with some subsonic implementations - # not recognizing the "enc:" precursor to the encoded password and - # encodes the whole "enc:" as the password. Weird. - #q = {'username': username, 'password': hexPass.lower()} - q = {'username': username, 'password': password} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getUser(self, username): - """ - since: 1.3.0 - - Get details about a given user, including which auth roles it has. - Can be used to enable/disable certain features in the client, such - as jukebox control - - username:str The username to retrieve. You can only retrieve - your own user unless you have admin privs. - - Returns a dict like the following: - - {u'status': u'ok', - u'user': {u'adminRole': False, - u'commentRole': False, - u'coverArtRole': False, - u'downloadRole': True, - u'jukeboxRole': False, - u'playlistRole': True, - u'podcastRole': False, - u'settingsRole': True, - u'streamRole': True, - u'uploadRole': True, - u'username': u'test'}, - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getUser' - viewName = '%s.view' % methodName - - q = {'username': username} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getUsers(self): - """ - since 1.8.0 - - Gets a list of users - - returns a dict like the following - - {u'status': u'ok', - u'users': {u'user': [{u'adminRole': True, - u'commentRole': True, - u'coverArtRole': True, - u'downloadRole': True, - u'jukeboxRole': True, - u'playlistRole': True, - u'podcastRole': True, - u'scrobblingEnabled': True, - u'settingsRole': True, - u'shareRole': True, - u'streamRole': True, - u'uploadRole': True, - u'username': u'user1'}, - ... - ... - ]}, - u'version': u'1.10.2', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getUsers' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def createUser(self, username, password, email, - ldapAuthenticated=False, adminRole=False, settingsRole=True, - streamRole=True, jukeboxRole=False, downloadRole=False, - uploadRole=False, playlistRole=False, coverArtRole=False, - commentRole=False, podcastRole=False, shareRole=False, - musicFolderId=None): - """ - since: 1.1.0 - - Creates a new subsonic user, using the parameters defined. See the - documentation at http://subsonic.org for more info on all the roles. - - username:str The username of the new user - password:str The password for the new user - email:str The email of the new user - - musicFolderId:int These are the only folders the user has access to - - Returns a dict like the following: - - {u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'createUser' - viewName = '%s.view' % methodName - hexPass = 'enc:%s' % self._hexEnc(password) - - q = self._getQueryDict({ - 'username': username, 'password': hexPass, 'email': email, - 'ldapAuthenticated': ldapAuthenticated, 'adminRole': adminRole, - 'settingsRole': settingsRole, 'streamRole': streamRole, - 'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole, - 'uploadRole': uploadRole, 'playlistRole': playlistRole, - 'coverArtRole': coverArtRole, 'commentRole': commentRole, - 'podcastRole': podcastRole, 'shareRole': shareRole, - 'musicFolderId': musicFolderId - }) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def updateUser(self, username, password=None, email=None, - ldapAuthenticated=False, adminRole=False, settingsRole=True, - streamRole=True, jukeboxRole=False, downloadRole=False, - uploadRole=False, playlistRole=False, coverArtRole=False, - commentRole=False, podcastRole=False, shareRole=False, - musicFolderId=None, maxBitRate=0): - """ - since 1.10.1 - - Modifies an existing Subsonic user. - - username:str The username of the user to update. - musicFolderId:int Only return results from the music folder - with the given ID. See getMusicFolders - maxBitRate:int The max bitrate for the user. 0 is unlimited - - All other args are the same as create user and you can update - whatever item you wish to update for the given username. - - Returns a dict like the following: - - {u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'updateUser' - viewName = '%s.view' % methodName - if password is not None: - password = 'enc:%s' % self._hexEnc(password) - q = self._getQueryDict({'username': username, 'password': password, - 'email': email, 'ldapAuthenticated': ldapAuthenticated, - 'adminRole': adminRole, - 'settingsRole': settingsRole, 'streamRole': streamRole, - 'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole, - 'uploadRole': uploadRole, 'playlistRole': playlistRole, - 'coverArtRole': coverArtRole, 'commentRole': commentRole, - 'podcastRole': podcastRole, 'shareRole': shareRole, - 'musicFolderId': musicFolderId, 'maxBitRate': maxBitRate - }) - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def deleteUser(self, username): - """ - since: 1.3.0 - - Deletes an existing Subsonic user. Of course, you must have admin - rights for this. - - username:str The username of the user to delete - - Returns a dict like the following: - - {u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'deleteUser' - viewName = '%s.view' % methodName - - q = {'username': username} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getChatMessages(self, since=1): - """ - since: 1.2.0 - - Returns the current visible (non-expired) chat messages. - - since:int Only return messages newer than this timestamp - - NOTE: All times returned are in MILLISECONDS since the Epoch, not - seconds! - - Returns a dict like the following: - {u'chatMessages': {u'chatMessage': {u'message': u'testing 123', - u'time': 1303411919872L, - u'username': u'admin'}}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getChatMessages' - viewName = '%s.view' % methodName - - q = {'since': self._ts2milli(since)} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def addChatMessage(self, message): - """ - since: 1.2.0 - - Adds a message to the chat log - - message:str The message to add - - Returns a dict like the following: - - {u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'addChatMessage' - viewName = '%s.view' % methodName - - q = {'message': message} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getAlbumList(self, ltype, size=10, offset=0, fromYear=None, - toYear=None, genre=None, musicFolderId=None): - """ - since: 1.2.0 - - Returns a list of random, newest, highest rated etc. albums. - Similar to the album lists on the home page of the Subsonic - web interface - - ltype:str The list type. Must be one of the following: random, - newest, highest, frequent, recent, - (since 1.8.0 -> )starred, alphabeticalByName, - alphabeticalByArtist - Since 1.10.1 you can use byYear and byGenre to - list albums in a given year range or genre. - size:int The number of albums to return. Max 500 - offset:int The list offset. Use for paging. Max 5000 - fromYear:int If you specify the ltype as "byYear", you *must* - specify fromYear - toYear:int If you specify the ltype as "byYear", you *must* - specify toYear - genre:str The name of the genre e.g. "Rock". You must specify - genre if you set the ltype to "byGenre" - musicFolderId:str Only return albums in the music folder with - the given ID. See getMusicFolders() - - Returns a dict like the following: - - {u'albumList': {u'album': [{u'artist': u'Hank Williams', - u'id': u'3264928374', - u'isDir': True, - u'parent': u'9238479283', - u'title': u'The Original Singles Collection...Plus'}, - {u'artist': u'Freundeskreis', - u'coverArt': u'9823749823', - u'id': u'23492834', - u'isDir': True, - u'parent': u'9827492374', - u'title': u'Quadratur des Kreises'}]}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getAlbumList' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'type': ltype, 'size': size, - 'offset': offset, 'fromYear': fromYear, 'toYear': toYear, - 'genre': genre, 'musicFolderId': musicFolderId}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getAlbumList2(self, ltype, size=10, offset=0, fromYear=None, - toYear=None, genre=None): - """ - since 1.8.0 - - Returns a list of random, newest, highest rated etc. albums. - This is similar to getAlbumList, but uses ID3 tags for - organization - - ltype:str The list type. Must be one of the following: random, - newest, highest, frequent, recent, - (since 1.8.0 -> )starred, alphabeticalByName, - alphabeticalByArtist - Since 1.10.1 you can use byYear and byGenre to - list albums in a given year range or genre. - size:int The number of albums to return. Max 500 - offset:int The list offset. Use for paging. Max 5000 - fromYear:int If you specify the ltype as "byYear", you *must* - specify fromYear - toYear:int If you specify the ltype as "byYear", you *must* - specify toYear - genre:str The name of the genre e.g. "Rock". You must specify - genre if you set the ltype to "byGenre" - - Returns a dict like the following: - {u'albumList2': {u'album': [{u'artist': u'Massive Attack', - u'artistId': 0, - u'coverArt': u'al-0', - u'created': u'2009-08-28T10:00:44', - u'duration': 3762, - u'id': 0, - u'name': u'100th Window', - u'songCount': 9}, - {u'artist': u'Massive Attack', - u'artistId': 0, - u'coverArt': u'al-5', - u'created': u'2003-11-03T22:00:00', - u'duration': 2715, - u'id': 5, - u'name': u'Blue Lines', - u'songCount': 9}]}, - u'status': u'ok', - u'version': u'1.8.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getAlbumList2' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'type': ltype, 'size': size, - 'offset': offset, 'fromYear': fromYear, 'toYear': toYear, - 'genre': genre}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getRandomSongs(self, size=10, genre=None, fromYear=None, - toYear=None, musicFolderId=None): - """ - since 1.2.0 - - Returns random songs matching the given criteria - - size:int The max number of songs to return. Max 500 - genre:str Only return songs from this genre - fromYear:int Only return songs after or in this year - toYear:int Only return songs before or in this year - musicFolderId:str Only return songs in the music folder with the - given ID. See getMusicFolders - - Returns a dict like the following: - - {u'randomSongs': {u'song': [{u'album': u'1998 EP - Airbag (How Am I Driving)', - u'artist': u'Radiohead', - u'bitRate': 320, - u'contentType': u'audio/mpeg', - u'duration': 129, - u'id': u'9284728934', - u'isDir': False, - u'isVideo': False, - u'parent': u'983249823', - u'path': u'Radiohead/1998 EP - Airbag (How Am I Driving)/06 - Melatonin.mp3', - u'size': 5177469, - u'suffix': u'mp3', - u'title': u'Melatonin'}, - {u'album': u'Mezmerize', - u'artist': u'System Of A Down', - u'bitRate': 214, - u'contentType': u'audio/mpeg', - u'coverArt': u'23849372894', - u'duration': 176, - u'id': u'28937492834', - u'isDir': False, - u'isVideo': False, - u'parent': u'92837492837', - u'path': u'System Of A Down/Mesmerize/10 - System Of A Down - Old School Hollywood.mp3', - u'size': 4751360, - u'suffix': u'mp3', - u'title': u'Old School Hollywood', - u'track': 10}]}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getRandomSongs' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'size': size, 'genre': genre, - 'fromYear': fromYear, 'toYear': toYear, - 'musicFolderId': musicFolderId}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getLyrics(self, artist=None, title=None): - """ - since: 1.2.0 - - Searches for and returns lyrics for a given song - - artist:str The artist name - title:str The song title - - Returns a dict like the following for - getLyrics('Bob Dylan', 'Blowin in the wind'): - - {u'lyrics': {u'artist': u'Bob Dylan', - u'content': u"How many roads must a man walk down", - u'title': u"Blowin' in the Wind"}, - u'status': u'ok', - u'version': u'1.5.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getLyrics' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'artist': artist, 'title': title}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def jukeboxControl(self, action, index=None, sids=[], gain=None, - offset=None): - """ - since: 1.2.0 - - NOTE: Some options were added as of API version 1.7.0 - - Controls the jukebox, i.e., playback directly on the server's - audio hardware. Note: The user must be authorized to control - the jukebox - - action:str The operation to perform. Must be one of: get, - start, stop, skip, add, clear, remove, shuffle, - setGain, status (added in API 1.7.0), - set (added in API 1.7.0) - index:int Used by skip and remove. Zero-based index of the - song to skip to or remove. - sids:str Used by "add" and "set". ID of song to add to the - jukebox playlist. Use multiple id parameters to - add many songs in the same request. Whether you - are passing one song or many into this, this - parameter MUST be a list - gain:float Used by setGain to control the playback volume. - A float value between 0.0 and 1.0 - offset:int (added in API 1.7.0) Used by "skip". Start playing - this many seconds into the track. - """ - methodName = 'jukeboxControl' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'action': action, 'index': index, - 'gain': gain, 'offset': offset}) - - req = None - if action == 'add': - # We have to deal with the sids - if not (isinstance(sids, list) or isinstance(sids, tuple)): - raise ArgumentError('If you are adding songs, "sids" must ' - 'be a list or tuple!') - req = self._getRequestWithList(viewName, 'id', sids, q) - else: - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getPodcasts(self, incEpisodes=True, pid=None): - """ - since: 1.6.0 - - Returns all podcast channels the server subscribes to and their - episodes. - - incEpisodes:bool (since: 1.9.0) Whether to include Podcast - episodes in the returned result. - pid:str (since: 1.9.0) If specified, only return - the Podcast channel with this ID. - - Returns a dict like the following: - {u'status': u'ok', - u'version': u'1.6.0', - u'xmlns': u'http://subsonic.org/restapi', - u'podcasts': {u'channel': {u'description': u"Dr Chris Smith...", - u'episode': [{u'album': u'Dr Karl and the Naked Scientist', - u'artist': u'BBC Radio 5 live', - u'bitRate': 64, - u'contentType': u'audio/mpeg', - u'coverArt': u'2f6f7074', - u'description': u'Dr Karl answers all your science related questions.', - u'duration': 2902, - u'genre': u'Podcast', - u'id': 0, - u'isDir': False, - u'isVideo': False, - u'parent': u'2f6f70742f737562736f6e69632f706f6463617374732f4472204b61726c20616e6420746865204e616b656420536369656e74697374', - u'publishDate': u'2011-08-17 22:06:00.0', - u'size': 23313059, - u'status': u'completed', - u'streamId': u'2f6f70742f737562736f6e69632f706f6463617374732f4472204b61726c20616e6420746865204e616b656420536369656e746973742f64726b61726c5f32303131303831382d30343036612e6d7033', - u'suffix': u'mp3', - u'title': u'DrKarl: Peppermints, Chillies & Receptors', - u'year': 2011}, - {u'description': u'which is warmer, a bath with bubbles in it or one without? Just one of the stranger science stories tackled this week by Dr Chris Smith and the Naked Scientists!', - u'id': 1, - u'publishDate': u'2011-08-14 21:05:00.0', - u'status': u'skipped', - u'title': u'DrKarl: how many bubbles in your bath? 15 AUG 11'}, - ... - {u'description': u'Dr Karl joins Rhod to answer all your science questions', - u'id': 9, - u'publishDate': u'2011-07-06 22:12:00.0', - u'status': u'skipped', - u'title': u'DrKarl: 8 Jul 11 The Strange Sound of the MRI Scanner'}], - u'id': 0, - u'status': u'completed', - u'title': u'Dr Karl and the Naked Scientist', - u'url': u'http://downloads.bbc.co.uk/podcasts/fivelive/drkarl/rss.xml'}} - } - - See also: http://subsonic.svn.sourceforge.net/viewvc/subsonic/trunk/subsonic-main/src/main/webapp/xsd/podcasts_example_1.xml?view=markup - """ - methodName = 'getPodcasts' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'includeEpisodes': incEpisodes, - 'id': pid}) - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getShares(self): - """ - since: 1.6.0 - - Returns information about shared media this user is allowed to manage - - Note that entry can be either a single dict or a list of dicts - - Returns a dict like the following: - - {u'status': u'ok', - u'version': u'1.6.0', - u'xmlns': u'http://subsonic.org/restapi', - u'shares': {u'share': [ - {u'created': u'2011-08-18T10:01:35', - u'entry': {u'artist': u'Alice In Chains', - u'coverArt': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e732f416c69636520496e20436861696e732f636f7665722e6a7067', - u'id': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e732f416c69636520496e20436861696e73', - u'isDir': True, - u'parent': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e73', - u'title': u'Alice In Chains'}, - u'expires': u'2012-08-18T10:01:35', - u'id': 0, - u'url': u'http://crustymonkey.subsonic.org/share/BuLbF', - u'username': u'admin', - u'visitCount': 0 - }]} - } - """ - methodName = 'getShares' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def createShare(self, shids=[], description=None, expires=None): - """ - since: 1.6.0 - - Creates a public URL that can be used by anyone to stream music - or video from the Subsonic server. The URL is short and suitable - for posting on Facebook, Twitter etc. Note: The user must be - authorized to share (see Settings > Users > User is allowed to - share files with anyone). - - shids:list[str] A list of ids of songs, albums or videos - to share. - description:str A description that will be displayed to - people visiting the shared media - (optional). - expires:float A timestamp pertaining to the time at - which this should expire (optional) - - This returns a structure like you would get back from getShares() - containing just your new share. - """ - methodName = 'createShare' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'description': description, - 'expires': self._ts2milli(expires)}) - req = self._getRequestWithList(viewName, 'id', shids, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def updateShare(self, shid, description=None, expires=None): - """ - since: 1.6.0 - - Updates the description and/or expiration date for an existing share - - shid:str The id of the share to update - description:str The new description for the share (optional). - expires:float The new timestamp for the expiration time of this - share (optional). - """ - methodName = 'updateShare' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'id': shid, 'description': description, - expires: self._ts2milli(expires)}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def deleteShare(self, shid): - """ - since: 1.6.0 - - Deletes an existing share - - shid:str The id of the share to delete - - Returns a standard response dict - """ - methodName = 'deleteShare' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'id': shid}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def setRating(self, id, rating): - """ - since: 1.6.0 - - Sets the rating for a music file - - id:str The id of the item (song/artist/album) to rate - rating:int The rating between 1 and 5 (inclusive), or 0 to remove - the rating - - Returns a standard response dict - """ - methodName = 'setRating' - viewName = '%s.view' % methodName - - try: - rating = int(rating) - except: - raise ArgumentError('Rating must be an integer between 0 and 5: ' - '%r' % rating) - if rating < 0 or rating > 5: - raise ArgumentError('Rating must be an integer between 0 and 5: ' - '%r' % rating) - - q = self._getQueryDict({'id': id, 'rating': rating}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getArtists(self): - """ - since 1.8.0 - - Similar to getIndexes(), but this method uses the ID3 tags to - determine the artist - - Returns a dict like the following: - {u'artists': {u'index': [{u'artist': {u'albumCount': 7, - u'coverArt': u'ar-0', - u'id': 0, - u'name': u'Massive Attack'}, - u'name': u'M'}, - {u'artist': {u'albumCount': 2, - u'coverArt': u'ar-1', - u'id': 1, - u'name': u'Tune-Yards'}, - u'name': u'T'}]}, - u'status': u'ok', - u'version': u'1.8.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getArtists' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getArtist(self, id): - """ - since 1.8.0 - - Returns the info (albums) for an artist. This method uses - the ID3 tags for organization - - id:str The artist ID - - Returns a dict like the following: - - {u'artist': {u'album': [{u'artist': u'Tune-Yards', - u'artistId': 1, - u'coverArt': u'al-7', - u'created': u'2012-01-30T12:35:33', - u'duration': 3229, - u'id': 7, - u'name': u'Bird-Brains', - u'songCount': 13}, - {u'artist': u'Tune-Yards', - u'artistId': 1, - u'coverArt': u'al-8', - u'created': u'2011-03-22T15:08:00', - u'duration': 2531, - u'id': 8, - u'name': u'W H O K I L L', - u'songCount': 10}], - u'albumCount': 2, - u'coverArt': u'ar-1', - u'id': 1, - u'name': u'Tune-Yards'}, - u'status': u'ok', - u'version': u'1.8.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getArtist' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'id': id}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getAlbum(self, id): - """ - since 1.8.0 - - Returns the info and songs for an album. This method uses - the ID3 tags for organization - - id:str The album ID - - Returns a dict like the following: - - {u'album': {u'artist': u'Massive Attack', - u'artistId': 0, - u'coverArt': u'al-0', - u'created': u'2009-08-28T10:00:44', - u'duration': 3762, - u'id': 0, - u'name': u'100th Window', - u'song': [{u'album': u'100th Window', - u'albumId': 0, - u'artist': u'Massive Attack', - u'artistId': 0, - u'bitRate': 192, - u'contentType': u'audio/mpeg', - u'coverArt': 2, - u'created': u'2009-08-28T10:00:57', - u'duration': 341, - u'genre': u'Rock', - u'id': 14, - u'isDir': False, - u'isVideo': False, - u'parent': 2, - u'path': u'Massive Attack/100th Window/01 - Future Proof.mp3', - u'size': 8184445, - u'suffix': u'mp3', - u'title': u'Future Proof', - u'track': 1, - u'type': u'music', - u'year': 2003}], - u'songCount': 9}, - u'status': u'ok', - u'version': u'1.8.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getAlbum' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'id': id}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getSong(self, id): - """ - since 1.8.0 - - Returns the info for a song. This method uses the ID3 - tags for organization - - id:str The song ID - - Returns a dict like the following: - {u'song': {u'album': u'W H O K I L L', - u'albumId': 8, - u'artist': u'Tune-Yards', - u'artistId': 1, - u'bitRate': 320, - u'contentType': u'audio/mpeg', - u'coverArt': 106, - u'created': u'2011-03-22T15:08:00', - u'discNumber': 1, - u'duration': 192, - u'genre': u'Indie Rock', - u'id': 120, - u'isDir': False, - u'isVideo': False, - u'parent': 106, - u'path': u'Tune Yards/Who Kill/10 Killa.mp3', - u'size': 7692656, - u'suffix': u'mp3', - u'title': u'Killa', - u'track': 10, - u'type': u'music', - u'year': 2011}, - u'status': u'ok', - u'version': u'1.8.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getSong' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'id': id}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getVideos(self): - """ - since 1.8.0 - - Returns all video files - - Returns a dict like the following: - {u'status': u'ok', - u'version': u'1.8.0', - u'videos': {u'video': {u'bitRate': 384, - u'contentType': u'video/x-matroska', - u'created': u'2012-08-26T13:36:44', - u'duration': 1301, - u'id': 130, - u'isDir': False, - u'isVideo': True, - u'path': u'South Park - 16x07 - Cartman Finds Love.mkv', - u'size': 287309613, - u'suffix': u'mkv', - u'title': u'South Park - 16x07 - Cartman Finds Love', - u'transcodedContentType': u'video/x-flv', - u'transcodedSuffix': u'flv'}}, - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getVideos' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getStarred(self, musicFolderId=None): - """ - since 1.8.0 - - musicFolderId:int Only return results from the music folder - with the given ID. See getMusicFolders - - Returns starred songs, albums and artists - - Returns a dict like the following: - {u'starred': {u'album': {u'album': u'Bird-Brains', - u'artist': u'Tune-Yards', - u'coverArt': 105, - u'created': u'2012-01-30T13:16:58', - u'id': 105, - u'isDir': True, - u'parent': 104, - u'starred': u'2012-08-26T13:18:34', - u'title': u'Bird-Brains'}, - u'song': [{u'album': u'Mezzanine', - u'albumId': 4, - u'artist': u'Massive Attack', - u'artistId': 0, - u'bitRate': 256, - u'contentType': u'audio/mpeg', - u'coverArt': 6, - u'created': u'2009-06-15T07:48:28', - u'duration': 298, - u'genre': u'Dub', - u'id': 72, - u'isDir': False, - u'isVideo': False, - u'parent': 6, - u'path': u'Massive Attack/Mezzanine/Massive Attack_02_mezzanine.mp3', - u'size': 9564160, - u'starred': u'2012-08-26T13:19:26', - u'suffix': u'mp3', - u'title': u'Risingson', - u'track': 2, - u'type': u'music'}, - {u'album': u'Mezzanine', - u'albumId': 4, - u'artist': u'Massive Attack', - u'artistId': 0, - u'bitRate': 256, - u'contentType': u'audio/mpeg', - u'coverArt': 6, - u'created': u'2009-06-15T07:48:25', - u'duration': 380, - u'genre': u'Dub', - u'id': 71, - u'isDir': False, - u'isVideo': False, - u'parent': 6, - u'path': u'Massive Attack/Mezzanine/Massive Attack_01_mezzanine.mp3', - u'size': 12179456, - u'starred': u'2012-08-26T13:19:03', - u'suffix': u'mp3', - u'title': u'Angel', - u'track': 1, - u'type': u'music'}]}, - u'status': u'ok', - u'version': u'1.8.0', - u'xmlns': u'http://subsonic.org/restapi'} - """ - methodName = 'getStarred' - viewName = '%s.view' % methodName - - q = {} - if musicFolderId: - q['musicFolderId'] = musicFolderId - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getStarred2(self, musicFolderId=None): - """ - since 1.8.0 - - musicFolderId:int Only return results from the music folder - with the given ID. See getMusicFolders - - Returns starred songs, albums and artists like getStarred(), - but this uses ID3 tags for organization - - Returns a dict like the following: - - **See the output from getStarred()** - """ - methodName = 'getStarred2' - viewName = '%s.view' % methodName - - q = {} - if musicFolderId: - q['musicFolderId'] = musicFolderId - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def updatePlaylist(self, lid, name=None, comment=None, songIdsToAdd=[], - songIndexesToRemove=[]): - """ - since 1.8.0 - - Updates a playlist. Only the owner of a playlist is allowed to - update it. - - lid:str The playlist id - name:str The human readable name of the playlist - comment:str The playlist comment - songIdsToAdd:list A list of song IDs to add to the playlist - songIndexesToRemove:list Remove the songs at the - 0 BASED INDEXED POSITIONS in the - playlist, NOT the song ids. Note that - this is always a list. - - Returns a normal status response dict - """ - methodName = 'updatePlaylist' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'playlistId': lid, 'name': name, - 'comment': comment}) - if not isinstance(songIdsToAdd, list) or isinstance(songIdsToAdd, - tuple): - songIdsToAdd = [songIdsToAdd] - if not isinstance(songIndexesToRemove, list) or isinstance( - songIndexesToRemove, tuple): - songIndexesToRemove = [songIndexesToRemove] - listMap = {'songIdToAdd': songIdsToAdd, - 'songIndexToRemove': songIndexesToRemove} - req = self._getRequestWithLists(viewName, listMap, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getAvatar(self, username): - """ - since 1.8.0 - - Returns the avatar for a user or None if the avatar does not exist - - username:str The user to retrieve the avatar for - - Returns the file-like object for reading or raises an exception - on error - """ - methodName = 'getAvatar' - viewName = '%s.view' % methodName - - q = {'username': username} - - req = self._getRequest(viewName, q) - try: - res = self._doBinReq(req) - except urllib2.HTTPError: - # Avatar is not set/does not exist, return None - return None - if isinstance(res, dict): - self._checkStatus(res) - return res - - def star(self, sids=[], albumIds=[], artistIds=[]): - """ - since 1.8.0 - - Attaches a star to songs, albums or artists - - sids:list A list of song IDs to star - albumIds:list A list of album IDs to star. Use this rather than - "sids" if the client access the media collection - according to ID3 tags rather than file - structure - artistIds:list The ID of an artist to star. Use this rather - than sids if the client access the media - collection according to ID3 tags rather - than file structure - - Returns a normal status response dict - """ - methodName = 'star' - viewName = '%s.view' % methodName - - if not isinstance(sids, list) or isinstance(sids, tuple): - sids = [sids] - if not isinstance(albumIds, list) or isinstance(albumIds, tuple): - albumIds = [albumIds] - if not isinstance(artistIds, list) or isinstance(artistIds, tuple): - artistIds = [artistIds] - listMap = {'id': sids, - 'albumId': albumIds, - 'artistId': artistIds} - req = self._getRequestWithLists(viewName, listMap) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def unstar(self, sids=[], albumIds=[], artistIds=[]): - """ - since 1.8.0 - - Removes a star to songs, albums or artists. Basically, the - same as star in reverse - - sids:list A list of song IDs to star - albumIds:list A list of album IDs to star. Use this rather than - "sids" if the client access the media collection - according to ID3 tags rather than file - structure - artistIds:list The ID of an artist to star. Use this rather - than sids if the client access the media - collection according to ID3 tags rather - than file structure - - Returns a normal status response dict - """ - methodName = 'unstar' - viewName = '%s.view' % methodName - - if not isinstance(sids, list) or isinstance(sids, tuple): - sids = [sids] - if not isinstance(albumIds, list) or isinstance(albumIds, tuple): - albumIds = [albumIds] - if not isinstance(artistIds, list) or isinstance(artistIds, tuple): - artistIds = [artistIds] - listMap = {'id': sids, - 'albumId': albumIds, - 'artistId': artistIds} - req = self._getRequestWithLists(viewName, listMap) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getGenres(self): - """ - since 1.9.0 - - Returns all genres - """ - methodName = 'getGenres' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getSongsByGenre(self, genre, count=10, offset=0, musicFolderId=None): - """ - since 1.9.0 - - Returns songs in a given genre - - genre:str The genre, as returned by getGenres() - count:int The maximum number of songs to return. Max is 500 - default: 10 - offset:int The offset if you are paging. default: 0 - musicFolderId:int Only return results from the music folder - with the given ID. See getMusicFolders - """ - methodName = 'getGenres' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'genre': genre, - 'count': count, - 'offset': offset, - 'musicFolderId': musicFolderId, - }) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def hls (self, mid, bitrate=None): - """ - since 1.8.0 - - Creates an HTTP live streaming playlist for streaming video or - audio HLS is a streaming protocol implemented by Apple and - works by breaking the overall stream into a sequence of small - HTTP-based file downloads. It's supported by iOS and newer - versions of Android. This method also supports adaptive - bitrate streaming, see the bitRate parameter. - - mid:str The ID of the media to stream - bitrate:str If specified, the server will attempt to limit the - bitrate to this value, in kilobits per second. If - this parameter is specified more than once, the - server will create a variant playlist, suitable - for adaptive bitrate streaming. The playlist will - support streaming at all the specified bitrates. - The server will automatically choose video dimensions - that are suitable for the given bitrates. - (since: 1.9.0) you may explicitly request a certain - width (480) and height (360) like so: - bitRate=1000@480x360 - - Returns the raw m3u8 file as a string - """ - methodName = 'hls' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'id': mid, 'bitrate': bitrate}) - req = self._getRequest(viewName, q) - try: - res = self._doBinReq(req) - except urllib2.HTTPError: - # Avatar is not set/does not exist, return None - return None - if isinstance(res, dict): - self._checkStatus(res) - return res.read() - - def refreshPodcasts(self): - """ - since: 1.9.0 - - Tells the server to check for new Podcast episodes. Note: The user - must be authorized for Podcast administration - """ - methodName = 'refreshPodcasts' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def createPodcastChannel(self, url): - """ - since: 1.9.0 - - Adds a new Podcast channel. Note: The user must be authorized - for Podcast administration - - url:str The URL of the Podcast to add - """ - methodName = 'createPodcastChannel' - viewName = '%s.view' % methodName - - q = {'url': url} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def deletePodcastChannel(self, pid): - """ - since: 1.9.0 - - Deletes a Podcast channel. Note: The user must be authorized - for Podcast administration - - pid:str The ID of the Podcast channel to delete - """ - methodName = 'deletePodcastChannel' - viewName = '%s.view' % methodName - - q = {'id': pid} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def deletePodcastEpisode(self, pid): - """ - since: 1.9.0 - - Deletes a Podcast episode. Note: The user must be authorized - for Podcast administration - - pid:str The ID of the Podcast episode to delete - """ - methodName = 'deletePodcastEpisode' - viewName = '%s.view' % methodName - - q = {'id': pid} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def downloadPodcastEpisode(self, pid): - """ - since: 1.9.0 - - Tells the server to start downloading a given Podcast episode. - Note: The user must be authorized for Podcast administration - - pid:str The ID of the Podcast episode to download - """ - methodName = 'downloadPodcastEpisode' - viewName = '%s.view' % methodName - - q = {'id': pid} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getInternetRadioStations(self): - """ - since: 1.9.0 - - Returns all internet radio stations - """ - methodName = 'getInternetRadioStations' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getBookmarks(self): - """ - since: 1.9.0 - - Returns all bookmarks for this user. A bookmark is a position - within a media file - """ - methodName = 'getBookmarks' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def createBookmark(self, mid, position, comment=None): - """ - since: 1.9.0 - - Creates or updates a bookmark (position within a media file). - Bookmarks are personal and not visible to other users - - mid:str The ID of the media file to bookmark. If a bookmark - already exists for this file, it will be overwritten - position:int The position (in milliseconds) within the media file - comment:str A user-defined comment - """ - methodName = 'createBookmark' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'id': mid, 'position': position, - 'comment': comment}) - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def deleteBookmark(self, mid): - """ - since: 1.9.0 - - Deletes the bookmark for a given file - - mid:str The ID of the media file to delete the bookmark from. - Other users' bookmarks are not affected - """ - methodName = 'deleteBookmark' - viewName = '%s.view' % methodName - - q = {'id': mid} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getArtistInfo(self, aid, count=20, includeNotPresent=False): - """ - since: 1.11.0 - - Returns artist info with biography, image URLS and similar artists - using data from last.fm - - aid:str The ID of the artist, album or song - count:int The max number of similar artists to return - includeNotPresent:bool Whether to return artists that are not - present in the media library - """ - methodName = 'getArtistInfo' - viewName = '%s.view' % methodName - - q = {'id': aid, 'count': count, - 'includeNotPresent': includeNotPresent} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getArtistInfo2(self, aid, count=20, includeNotPresent=False): - """ - since: 1.11.0 - - Similar to getArtistInfo(), but organizes music according to ID3 tags - - aid:str The ID of the artist, album or song - count:int The max number of similar artists to return - includeNotPresent:bool Whether to return artists that are not - present in the media library - """ - methodName = 'getArtistInfo2' - viewName = '%s.view' % methodName - - q = {'id': aid, 'count': count, - 'includeNotPresent': includeNotPresent} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getSimilarSongs(self, iid, count=50): - """ - since 1.11.0 - - Returns a random collection of songs from the given artist and - similar artists, using data from last.fm. Typically used for - artist radio features. - - iid:str The artist, album, or song ID - count:int Max number of songs to return - """ - methodName = 'getSimilarSongs' - viewName = '%s.view' % methodName - - q = {'id': iid, 'count': count} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getSimilarSongs2(self, iid, count=50): - """ - since 1.11.0 - - Similar to getSimilarSongs(), but organizes music according to - ID3 tags - - iid:str The artist, album, or song ID - count:int Max number of songs to return - """ - methodName = 'getSimilarSongs2' - viewName = '%s.view' % methodName - - q = {'id': iid, 'count': count} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def savePlayQueue(self, qids, current=None, position=None): - """ - since 1.12.0 - - qid:list[int] The list of song ids in the play queue - current:int The id of the current playing song - position:int The position, in milliseconds, within the current - playing song - - Saves the state of the play queue for this user. This includes - the tracks in the play queue, the currently playing track, and - the position within this track. Typically used to allow a user to - move between different clients/apps while retaining the same play - queue (for instance when listening to an audio book). - """ - methodName = 'savePlayQueue' - viewName = '%s.view' % methodName - if not isinstance(qids, (tuple, list)): - qids = [qids] - - q = self._getQueryDict({'current': current, 'position': position}) - - req = self._getRequestWithLists(viewName, {'id': qids}, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getPlayQueue(self): - """ - since 1.12.0 - - Returns the state of the play queue for this user (as set by - savePlayQueue). This includes the tracks in the play queue, - the currently playing track, and the position within this track. - Typically used to allow a user to move between different - clients/apps while retaining the same play queue (for instance - when listening to an audio book). - """ - methodName = 'getPlayQueue' - viewName = '%s.view' % methodName - - req = self._getRequest(viewName) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getTopSongs(self, artist, count=50): - """ - since 1.13.0 - - Returns the top songs for a given artist - - artist:str The artist to get songs for - count:int The number of songs to return - """ - methodName = 'getTopSongs' - viewName = '%s.view' % methodName - - q = {'artist': artist, 'count': count} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getNewestPodcasts(self, count=20): - """ - since 1.13.0 - - Returns the most recently published Podcast episodes - - count:int The number of episodes to return - """ - methodName = 'getNewestPodcasts' - viewName = '%s.view' % methodName - - q = {'count': count} - - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def scanMediaFolders(self): - """ - This is not an officially supported method of the API - - Same as selecting 'Settings' > 'Scan media folders now' with - Subsonic web GUI - - Returns True if refresh successful, False otherwise - """ - methodName = 'scanNow' - return self._unsupportedAPIFunction(methodName) - - def cleanupDatabase(self): - """ - This is not an officially supported method of the API - - Same as selecting 'Settings' > 'Clean-up Database' with Subsonic - web GUI - - Returns True if cleanup initiated successfully, False otherwise - - Subsonic stores information about all media files ever encountered. - By cleaning up the database, information about files that are - no longer in your media collection is permanently removed. - """ - methodName = 'expunge' - return self._unsupportedAPIFunction(methodName) - - def getVideoInfo(self, vid): - """ - since 1.14.0 - - Returns details for a video, including information about available - audio tracks, subtitles (captions) and conversions. - - vid:int The video ID - """ - methodName = 'getVideoInfo' - viewName = '%s.view' % methodName - - q = {'id': int(vid)} - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getAlbumInfo(self, aid): - """ - since 1.14.0 - - Returns the album notes, image URLs, etc., using data from last.fm - - aid:int The album ID - """ - methodName = 'getAlbumInfo' - viewName = '%s.view' % methodName - - q = {'id': int(aid)} - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getAlbumInfo2(self, aid): - """ - since 1.14.0 - - Same as getAlbumInfo, but uses ID3 tags - - aid:int The album ID - """ - methodName = 'getAlbumInfo2' - viewName = '%s.view' % methodName - - q = {'id': int(aid)} - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def getCaptions(self, vid, fmt=None): - """ - since 1.14.0 - - Returns captions (subtitles) for a video. Use getVideoInfo for a list - of captions. - - vid:int The ID of the video - fmt:str Preferred captions format ("srt" or "vtt") - """ - methodName = 'getCaptions' - viewName = '%s.view' % methodName - - q = self._getQueryDict({'id': int(vid), 'format': fmt}) - req = self._getRequest(viewName, q) - res = self._doInfoReq(req) - self._checkStatus(res) - return res - - def _unsupportedAPIFunction(self, methodName): - """ - base function to call unsupported API methods - - Returns True if refresh successful, False otherwise - :rtype : boolean - """ - baseMethod = 'musicFolderSettings' - viewName = '%s.view' % baseMethod - - url = '%s:%d/%s/%s?%s' % (self._baseUrl, self._port, - self._separateServerPath(), viewName, methodName) - req = urllib2.Request(url) - res = self._opener.open(req) - res_msg = res.msg.lower() - return res_msg == 'ok' - - # - # Private internal methods - # - def _getOpener(self, username, passwd): - # Context is only relevent in >= python 2.7.9 - https_chain = HTTPSHandlerChain() - if sys.version_info[:3] >= (2, 7, 9) and self._insecure: - https_chain = HTTPSHandlerChain( - context=ssl._create_unverified_context()) - opener = urllib2.build_opener(PysHTTPRedirectHandler, https_chain) - return opener - - def _getQueryDict(self, d): - """ - Given a dictionary, it cleans out all the values set to None - """ - for k, v in d.items(): - if v is None: - del d[k] - return d - - def _getBaseQdict(self): - qdict = { - 'f': 'json', - 'v': self._apiVersion, - 'c': self._appName, - 'u': self._username, - } - - if self._legacyAuth: - qdict['p'] = 'enc:%s' % self._hexEnc(self._rawPass) - else: - salt = self._getSalt() - token = md5(self._rawPass + salt).hexdigest() - qdict.update({ - 's': salt, - 't': token, - }) - - return qdict - - def _getRequest(self, viewName, query={}): - qdict = self._getBaseQdict() - qdict.update(query) - url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath, - viewName) - req = urllib2.Request(url, urlencode(qdict)) - - if self._useGET: - url += '?%s' % urlencode(qdict) - req = urllib2.Request(url) - - return req - - def _getRequestWithList(self, viewName, listName, alist, query={}): - """ - Like _getRequest, but allows appending a number of items with the - same key (listName). This bypasses the limitation of urlencode() - """ - qdict = self._getBaseQdict() - qdict.update(query) - url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath, - viewName) - data = StringIO() - data.write(urlencode(qdict)) - for i in alist: - data.write('&%s' % urlencode({listName: i})) - req = urllib2.Request(url, data.getvalue()) - - if self._useGET: - url += '?%s' % data.getvalue() - req = urllib2.Request(url) - - return req - - def _getRequestWithLists(self, viewName, listMap, query={}): - """ - Like _getRequestWithList(), but you must pass a dictionary - that maps the listName to the list. This allows for multiple - list parameters to be used, like in updatePlaylist() - - viewName:str The name of the view - listMap:dict A mapping of listName to a list of entries - query:dict The normal query dict - """ - qdict = self._getBaseQdict() - qdict.update(query) - url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath, - viewName) - data = StringIO() - data.write(urlencode(qdict)) - for k, l in listMap.iteritems(): - for i in l: - data.write('&%s' % urlencode({k: i})) - req = urllib2.Request(url, data.getvalue()) - - if self._useGET: - url += '?%s' % data.getvalue() - req = urllib2.Request(url) - - return req - - def _doInfoReq(self, req): - # Returns a parsed dictionary version of the result - res = self._opener.open(req) - dres = json.loads(res.read()) - return dres['subsonic-response'] - - def _doBinReq(self, req): - res = self._opener.open(req) - contType = res.info().getheader('Content-Type') - if contType: - if contType.startswith('text/html') or \ - contType.startswith('application/json'): - dres = json.loads(res.read()) - return dres['subsonic-response'] - return res - - def _checkStatus(self, result): - if result['status'] == 'ok': - return True - elif result['status'] == 'failed': - exc = getExcByCode(result['error']['code']) - raise exc(result['error']['message']) - - def _hexEnc(self, raw): - """ - Returns a "hex encoded" string per the Subsonic api docs - - raw:str The string to hex encode - """ - ret = '' - for c in raw: - ret += '%02X' % ord(c) - return ret - - def _ts2milli(self, ts): - """ - For whatever reason, Subsonic uses timestamps in milliseconds since - the unix epoch. I have no idea what need there is of this precision, - but this will just multiply the timestamp times 1000 and return the int - """ - if ts is None: - return None - return int(ts * 1000) - - def _separateServerPath(self): - """ - separate REST portion of URL from base server path. - """ - return urllib2.splithost(self._serverPath)[1].split('/')[0] - - def _fixLastModified(self, data): - """ - This will recursively walk through a data structure and look for - a dict key/value pair where the key is "lastModified" and change - the shitty java millisecond timestamp to a real unix timestamp - of SECONDS since the unix epoch. JAVA SUCKS! - """ - if isinstance(data, dict): - for k, v in data.items(): - if k == 'lastModified': - data[k] = long(v) / 1000.0 - return - elif isinstance(v, (tuple, list, dict)): - return self._fixLastModified(v) - elif isinstance(data, (list, tuple)): - for item in data: - if isinstance(item, (list, tuple, dict)): - return self._fixLastModified(item) - - def _process_netrc(self, use_netrc): - """ - The use_netrc var is either a boolean, which means we should use - the user's default netrc, or a string specifying a path to a - netrc formatted file - - use_netrc:bool|str Either set to True to use the user's default - netrc file or a string specifying a specific - netrc file to use - """ - if not use_netrc: - raise CredentialError('useNetrc must be either a boolean "True" ' - 'or a string representing a path to a netrc file, ' - 'not {0}'.format(repr(use_netrc))) - if isinstance(use_netrc, bool) and use_netrc: - self._netrc = netrc() - else: - # This should be a string specifying a path to a netrc file - self._netrc = netrc(os.path.expanduser(use_netrc)) - auth = self._netrc.authenticators(self._hostname) - if not auth: - raise CredentialError('No machine entry found for {0} in ' - 'your netrc file'.format(self._hostname)) - - # If we get here, we have credentials - self._username = auth[0] - self._rawPass = auth[2] - - def _getSalt(self, length=12): - salt = md5(os.urandom(100)).hexdigest() - return salt[:length] diff --git a/lib/py-sonic/libsonic/errors.py b/lib/py-sonic/libsonic/errors.py deleted file mode 100644 index a000c57..0000000 --- a/lib/py-sonic/libsonic/errors.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -This file is part of py-sonic. - -py-sonic 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. - -py-sonic 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 py-sonic. If not, see -""" - -class SonicError(Exception): - pass - -class ParameterError(SonicError): - pass - -class VersionError(SonicError): - pass - -class CredentialError(SonicError): - pass - -class AuthError(SonicError): - pass - -class LicenseError(SonicError): - pass - -class DataNotFoundError(SonicError): - pass - -class ArgumentError(SonicError): - pass - -# This maps the error code numbers from the Subsonic server to their -# appropriate Exceptions -ERR_CODE_MAP = { - 0: SonicError , - 10: ParameterError , - 20: VersionError , - 30: VersionError , - 40: CredentialError , - 50: AuthError , - 60: LicenseError , - 70: DataNotFoundError , -} - -def getExcByCode(code): - code = int(code) - if code in ERR_CODE_MAP: - return ERR_CODE_MAP[code] - return SonicError diff --git a/lib/py-sonic/setup.py b/lib/py-sonic/setup.py deleted file mode 100644 index fb4ce5a..0000000 --- a/lib/py-sonic/setup.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python - -""" -This file is part of py-sonic. - -py-sonic 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. - -py-sonic 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 py-sonic. If not, see -""" - -from distutils.core import setup -from libsonic import __version__ as version - -setup(name='py-sonic', - version=version, - author='Jay Deiman', - author_email='admin@splitstreams.com', - url='http://stuffivelearned.org', - description='A python wrapper library for the Subsonic REST API. ' - 'http://subsonic.org', - long_description='This is a basic wrapper library for the Subsonic ' - 'REST API. This will allow you to connect to your server and retrieve ' - 'information and have it returned in basic Python types.', - packages=['libsonic'], - package_dir={'libsonic': 'libsonic'}, - classifiers=[ - 'Development Status :: 4 - Beta', - 'Intended Audience :: System Administrators', - 'Intended Audience :: Information Technology', - 'License :: OSI Approved :: GNU General Public License (GPL)', - 'Natural Language :: English', - 'Operating System :: POSIX', - 'Programming Language :: Python', - 'Topic :: System :: Systems Administration', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Software Development :: Libraries', - 'Topic :: System', - ] -) diff --git a/lib/simpleplugin/__init__.py b/lib/simpleplugin/__init__.py new file mode 100644 index 0000000..337f20e --- /dev/null +++ b/lib/simpleplugin/__init__.py @@ -0,0 +1,2 @@ +#v2.0.1 +#https://github.com/romanvm/script.module.simpleplugin/releases \ No newline at end of file diff --git a/lib/simpleplugin/simpleplugin.py b/lib/simpleplugin/simpleplugin.py new file mode 100644 index 0000000..8fc6975 --- /dev/null +++ b/lib/simpleplugin/simpleplugin.py @@ -0,0 +1,915 @@ +# -*- coding: utf-8 -*- +# Created on: 03.06.2015 +""" +SimplePlugin micro-framework for Kodi content plugins + +**Author**: Roman Miroshnychenko aka Roman V.M. + +**License**: `GPL v.3 `_ +""" + +import os +import sys +import re +from datetime import datetime, timedelta +import cPickle as pickle +from urlparse import parse_qs +from urllib import urlencode +from functools import wraps +from collections import MutableMapping, namedtuple +from copy import deepcopy +from types import GeneratorType +from hashlib import md5 +from shutil import move +import xbmcaddon +import xbmc +import xbmcplugin +import xbmcgui + +__all__ = ['SimplePluginError', 'Storage', 'Addon', 'Plugin', 'Params'] + +ListContext = namedtuple('ListContext', ['listing', 'succeeded', 'update_listing', 'cache_to_disk', + 'sort_methods', 'view_mode', 'content']) +PlayContext = namedtuple('PlayContext', ['path', 'play_item', 'succeeded']) + + +class SimplePluginError(Exception): + """Custom exception""" + pass + + +class Params(dict): + """ + Params(**kwargs) + + A class that stores parsed plugin call parameters + + Parameters can be accessed both through :class:`dict` keys and + instance properties. + + Example: + + .. code-block:: python + + @plugin.action('foo') + def action(params): + foo = params['foo'] # Access by key + bar = params.bar # Access through property. Both variants are equal + """ + def __getattr__(self, item): + if item not in self: + raise AttributeError('Invalid parameter: "{0}"!'.format(item)) + return self[item] + + def __str__(self): + return ''.format(super(Params, self).__repr__()) + + def __repr__(self): + return ''.format(super(Params, self).__repr__()) + + +class Storage(MutableMapping): + """ + Persistent storage for arbitrary data with a dictionary-like interface + + It is designed as a context manager and better be used + with 'with' statement. + + :param storage_dir: directory for storage + :type storage_dir: str + :param filename: the name of a storage file (optional) + :type filename: str + + Usage:: + + with Storage('/foo/bar/storage/') as storage: + storage['key1'] = value1 + value2 = storage['key2'] + + .. note:: After exiting :keyword:`with` block a :class:`Storage` instance is invalidated. + Storage contents are saved to disk only for a new storage or if the contents have been changed. + """ + def __init__(self, storage_dir, filename='storage.pcl'): + """ + Class constructor + """ + self._storage = {} + self._hash = None + self._filename = os.path.join(storage_dir, filename) + try: + with open(self._filename, 'rb') as fo: + contents = fo.read() + self._storage = pickle.loads(contents) + self._hash = md5(contents).hexdigest() + except (IOError, pickle.PickleError, EOFError): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.flush() + + def __getitem__(self, key): + return self._storage[key] + + def __setitem__(self, key, value): + self._storage[key] = value + + def __delitem__(self, key): + del self._storage[key] + + def __iter__(self): + return self._storage.__iter__() + + def __len__(self): + return len(self._storage) + + def __str__(self): + return ''.format(self._storage) + + def __repr__(self): + return ''.format(self._storage) + + def flush(self): + """ + Save storage contents to disk + + This method saves new and changed :class:`Storage` contents to disk + and invalidates the Storage instance. Unchanged Storage is not saved + but simply invalidated. + """ + contents = pickle.dumps(self._storage) + if self._hash is None or md5(contents).hexdigest() != self._hash: + tmp = self._filename + '.tmp' + try: + with open(tmp, 'wb') as fo: + fo.write(contents) + except: + os.remove(tmp) + raise + move(tmp, self._filename) # Atomic save + del self._storage + + def copy(self): + """ + Make a copy of storage contents + + .. note:: this method performs a *deep* copy operation. + + :return: a copy of storage contents + :rtype: dict + """ + return deepcopy(self._storage) + + +class Addon(object): + """ + Base addon class + + Provides access to basic addon parameters + + :param id_: addon id, e.g. 'plugin.video.foo' (optional) + :type id_: str + """ + def __init__(self, id_=''): + """ + Class constructor + """ + self._addon = xbmcaddon.Addon(id_) + self._configdir = xbmc.translatePath(self._addon.getAddonInfo('profile')).decode('utf-8') + self._ui_strings_map = None + if not os.path.exists(self._configdir): + os.mkdir(self._configdir) + + def __getattr__(self, item): + """ + Get addon setting as an Addon instance attribute + + E.g. addon.my_setting is equal to addon.get_setting('my_setting') + + :param item: + :type item: str + """ + return self.get_setting(item) + + def __str__(self): + return ''.format(self.id) + + def __repr__(self): + return ''.format(self.id) + + @property + def addon(self): + """ + Kodi Addon instance that represents this Addon + + :return: Addon instance + :rtype: xbmcaddon.Addon + """ + return self._addon + + @property + def id(self): + """ + Addon ID + + :return: Addon ID, e.g. 'plugin.video.foo' + :rtype: str + """ + return self._addon.getAddonInfo('id') + + @property + def path(self): + """ + Addon path + + :return: path to the addon folder + :rtype: str + """ + return self._addon.getAddonInfo('path').decode('utf-8') + + @property + def icon(self): + """ + Addon icon + + :return: path to the addon icon image + :rtype: str + """ + icon = os.path.join(self.path, 'icon.png') + if os.path.exists(icon): + return icon + else: + return '' + + @property + def fanart(self): + """ + Addon fanart + + :return: path to the addon fanart image + :rtype: str + """ + fanart = os.path.join(self.path, 'fanart.jpg') + if os.path.exists(fanart): + return fanart + else: + return '' + + @property + def config_dir(self): + """ + Addon config dir + + :return: path to the addon config dir + :rtype: str + """ + return self._configdir + + def get_localized_string(self, id_): + """ + Get localized UI string + + :param id_: UI string ID + :type id_: int + :return: UI string in the current language + :rtype: unicode + """ + return self._addon.getLocalizedString(id_).encode('utf-8') + + def get_setting(self, id_, convert=True): + """ + Get addon setting + + If ``convert=True``, 'bool' settings are converted to Python :class:`bool` values, + and numeric strings to Python :class:`long` or :class:`float` depending on their format. + + .. note:: Settings can also be read via :class:`Addon` instance poperties named as the respective settings. + I.e. ``addon.foo`` is equal to ``addon.get_setting('foo')``. + + :param id_: setting ID + :type id_: str + :param convert: try to guess and convert the setting to an appropriate type + E.g. ``'1.0'`` will be converted to float ``1.0`` number, ``'true'`` to ``True`` and so on. + :type convert: bool + :return: setting value + """ + setting = self._addon.getSetting(id_) + if convert: + if setting == 'true': + return True # Convert boolean strings to bool + elif setting == 'false': + return False + elif re.search(r'^-?\d+$', setting) is not None: + return long(setting) # Convert numeric strings to long + elif re.search(r'^-?\d+\.\d+$', setting) is not None: + return float(setting) # Convert numeric strings with a dot to float + return setting + + def set_setting(self, id_, value): + """ + Set addon setting + + Python :class:`bool` type are converted to ``'true'`` or ``'false'`` + Non-string/non-unicode values are converted to strings. + + .. warning:: Setting values via :class:`Addon` instance properties is not supported! + Values can only be set using :meth:`Addon.set_setting` method. + + :param id_: setting ID + :type id_: str + :param value: setting value + """ + if isinstance(value, bool): + value = 'true' if value else 'false' + elif not isinstance(value, basestring): + value = str(value) + self._addon.setSetting(id_, value) + + def log(self, message, level=0): + """ + Add message to Kodi log starting with Addon ID + + :param message: message to be written into the Kodi log + :type message: str + :param level: log level. :mod:`xbmc` module provides the necessary symbolic constants. + Default: ``xbmc.LOGDEBUG`` + :type level: int + """ + if isinstance(message, unicode): + message = message.encode('utf-8') + xbmc.log('{0}: {1}'.format(self.id, message), level) + + def log_notice(self, message): + """ + Add NOTICE message to the Kodi log + + :param message: message to write to the Kodi log + :type message: str + """ + self.log(message, xbmc.LOGINFO) + + def log_warning(self, message): + """ + Add WARNING message to the Kodi log + + :param message: message to write to the Kodi log + :type message: str + """ + self.log(message, xbmc.LOGWARNING) + + def log_error(self, message): + """ + Add ERROR message to the Kodi log + + :param message: message to write to the Kodi log + :type message: str + """ + self.log(message, xbmc.LOGERROR) + + def log_debug(self, message): + """ + Add debug message to the Kodi log + + :param message: message to write to the Kodi log + :type message: str + """ + self.log(message, xbmc.LOGDEBUG) + + def get_storage(self, filename='storage.pcl'): + """ + Get a persistent :class:`Storage` instance for storing arbitrary values between addon calls. + + A :class:`Storage` instance can be used as a context manager. + + Example:: + + with plugin.get_storage() as storage: + storage['param1'] = value1 + value2 = storage['param2'] + + .. note:: After exiting :keyword:`with` block a :class:`Storage` instance is invalidated. + + :param filename: the name of a storage file (optional) + :type filename: str + :return: Storage object + :rtype: Storage + """ + return Storage(self.config_dir, filename) + + def cached(self, duration=10): + """ + Cached decorator + + Used to cache function return data + + Usage:: + + @plugin.cached(30) + def my_func(*args, **kwargs): + # Do some stuff + return value + + :param duration: caching duration in min (positive values only) + :type duration: int + :raises ValueError: if duration is zero or negative + """ + def outer_wrapper(func): + @wraps(func) + def inner_wrapper(*args, **kwargs): + with self.get_storage('__cache__.pcl') as cache: + current_time = datetime.now() + key = func.__name__ + str(args) + str(kwargs) + try: + data, timestamp = cache[key] + if duration > 0 and current_time - timestamp > timedelta(minutes=duration): + raise KeyError + elif duration <= 0: + raise ValueError('Caching duration cannot be zero or negative!') + self.log_debug('Cache hit: {0}'.format(key)) + except KeyError: + self.log_debug('Cache miss: {0}'.format(key)) + data = func(*args, **kwargs) + cache[key] = (data, current_time) + return data + return inner_wrapper + return outer_wrapper + + def gettext(self, ui_string): + """ + Get a translated UI string from addon localization files. + + This function emulates GNU Gettext for more convenient access + to localized addon UI strings. To reduce typing this method object + can be assigned to a ``_`` (single underscore) variable. + + For using gettext emulation :meth:`Addon.initialize_gettext` method + needs to be called first. See documentation for that method for more info + about Gettext emulation. + + :param ui_string: a UI string from English :file:`strings.po`. + :type ui_string: str + :return: a UI string from translated :file:`strings.po`. + :rtype: unicode + :raises simpleplugin.SimplePluginError: if :meth:`Addon.initialize_gettext` wasn't called first + or if a string is not found in English :file:`strings.po`. + """ + if self._ui_strings_map is not None: + try: + return self.get_localized_string(self._ui_strings_map['strings'][ui_string]) + except KeyError: + raise SimplePluginError('UI string "{0}" is not found in strings.po!'.format(ui_string)) + else: + raise SimplePluginError('Addon localization is not initialized!') + + def initialize_gettext(self): + """ + Initialize GNU gettext emulation in addon + + Kodi localization system for addons is not very convenient + because you need to operate with numeric string codes instead + of UI strings themselves which reduces code readability and + may lead to errors. The :class:`Addon` class provides facilities + for emulating GNU Gettext localization system. + + This allows to use UI strings from addon's English :file:`strings.po` + file instead of numeric codes to return localized strings from + respective localized :file:`.po` files. + + This method returns :meth:`Addon.gettext` method object that + can be assigned to a short alias to reduce typing. Traditionally, + ``_`` (a single underscore) is used for this purpose. + + Example:: + + addon = simpleplugin.Addon() + _ = addon.initialize_gettext() + + xbmcgui.Dialog().notification(_('Warning!'), _('Something happened')) + + In the preceding example the notification strings will be replaced + with localized versions if these strings are translated. + + :return: :meth:`Addon.gettext` method object + :raises simpleplugin.SimplePluginError: if the addon's English :file:`strings.po` file is missing + """ + strings_po = os.path.join(self.path, 'resources', 'language', 'English', 'strings.po') + if os.path.exists(strings_po): + with open(strings_po, 'rb') as fo: + raw_strings = fo.read() + raw_strings_hash = md5(raw_strings).hexdigest() + gettext_pcl = '__gettext__.pcl' + with self.get_storage(gettext_pcl) as ui_strings_map: + if (not os.path.exists(os.path.join(self._configdir, gettext_pcl)) or + raw_strings_hash != ui_strings_map['hash']): + ui_strings = self._parse_po(raw_strings.split('\n')) + self._ui_strings_map = { + 'hash': raw_strings_hash, + 'strings': ui_strings + } + ui_strings_map['hash'] = raw_strings_hash + ui_strings_map['strings'] = ui_strings.copy() + else: + self._ui_strings_map = deepcopy(ui_strings_map) + else: + raise SimplePluginError('Unable to initialize localization because of missing English strings.po!') + return self.gettext + + def _parse_po(self, strings): + """ + Parses ``strings.po`` file into a dict of {'string': id} items. + """ + ui_strings = {} + string_id = None + for string in strings: + if string_id is None and 'msgctxt' in string: + string_id = int(re.search(r'"#(\d+)"', string).group(1)) + elif string_id is not None and 'msgid' in string: + ui_strings[re.search(r'"(.*?)"', string, re.U).group(1)] = string_id + string_id = None + return ui_strings + + +class Plugin(Addon): + """ + Plugin class + + :param id_: plugin's id, e.g. 'plugin.video.foo' (optional) + :type id_: str + + This class provides a simplified API to create virtual directories of playable items + for Kodi content plugins. + :class:`simpleplugin.Plugin` uses a concept of callable plugin actions (functions or methods) + that are defined using :meth:`Plugin.action` decorator. + A Plugin instance must have at least one action that is named ``'root'``. + + Minimal example: + + .. code-block:: python + + from simpleplugin import Plugin + + plugin = Plugin() + + @plugin.action() + def root(params): # Mandatory item! + return [{'label': 'Foo', + 'url': plugin.get_url(action='some_action', param='Foo')}, + {'label': 'Bar', + 'url': plugin.get_url(action='some_action', param='Bar')}] + + @plugin.action() + def some_action(params): + return [{'label': params['param']}] + + plugin.run() + + An action callable receives 1 parameter -- params. + params is a dict-like object containing plugin call parameters (including action string) + The action callable can return + either a list/generator of dictionaries representing Kodi virtual directory items + or a resolved playable path (:class:`str` or :obj:`unicode`) for Kodi to play. + + Example 1:: + + @plugin.action() + def list_action(params): + listing = get_listing(params) # Some external function to create listing + return listing + + The ``listing`` variable is a Python list/generator of dict items. + Example 2:: + + @plugin.action() + def play_action(params): + path = get_path(params) # Some external function to get a playable path + return path + + Each dict item can contain the following properties: + + - label -- item's label (default: ``''``). + - label2 -- item's label2 (default: ``''``). + - thumb -- item's thumbnail (default: ``''``). + - icon -- item's icon (default: ``''``). + - path -- item's path (default: ``''``). + - fanart -- item's fanart (optional). + - art -- a dict containing all item's graphic (see :meth:`xbmcgui.ListItem.setArt` for more info) -- optional. + - stream_info -- a dictionary of ``{stream_type: {param: value}}`` items + (see :meth:`xbmcgui.ListItem.addStreamInfo`) -- optional. + - info -- a dictionary of ``{media: {param: value}}`` items + (see :meth:`xbmcgui.ListItem.setInfo`) -- optional + - context_menu - a list that contains 2-item tuples ``('Menu label', 'Action')``. + The items from the tuples are added to the item's context menu. + - url -- a callback URL for this list item. + - is_playable -- if ``True``, then this item is playable and must return a playable path or + be resolved via :meth:`Plugin.resolve_url` (default: ``False``). + - is_folder -- if ``True`` then the item will open a lower-level sub-listing. if ``False``, + the item either is a playable media or a general-purpose script + which neither creates a virtual folder nor points to a playable media (default: C{True}). + if ``'is_playable'`` is set to ``True``, then ``'is_folder'`` value automatically assumed to be ``False``. + - subtitles -- the list of paths to subtitle files (optional). + - mime -- item's mime type (optional). + - list_item -- an 'class:`xbmcgui.ListItem` instance (optional). + It is used when you want to set all list item properties by yourself. + If ``'list_item'`` property is present, all other properties, + except for ``'url'`` and ``'is_folder'``, are ignored. + - properties -- a dictionary of list item properties + (see :meth:`xbmcgui.ListItem.setProperty`) -- optional. + + Example 3:: + + listing = [{ 'label': 'Label', + 'label2': 'Label 2', + 'thumb': 'thumb.png', + 'icon': 'icon.png', + 'fanart': 'fanart.jpg', + 'art': {'clearart': 'clearart.png'}, + 'stream_info': {'video': {'codec': 'h264', 'duration': 1200}, + 'audio': {'codec': 'ac3', 'language': 'en'}}, + 'info': {'video': {'genre': 'Comedy', 'year': 2005}}, + 'context_menu': [('Menu Item', 'Action')], + 'url': 'plugin:/plugin.video.test/?action=play', + 'is_playable': True, + 'is_folder': False, + 'subtitles': ['/path/to/subtitles.en.srt', '/path/to/subtitles.uk.srt'], + 'mime': 'video/mp4' + }] + + Alternatively, an action callable can use :meth:`Plugin.create_listing` and :meth:`Plugin.resolve_url` + static methods to pass additional parameters to Kodi. + + Example 4:: + + @plugin.action() + def list_action(params): + listing = get_listing(params) # Some external function to create listing + return Plugin.create_listing(listing, sort_methods=(2, 10, 17), view_mode=500) + + Example 5:: + + @plugin.action() + def play_action(params): + path = get_path(params) # Some external function to get a playable path + return Plugin.resolve_url(path, succeeded=True) + + If an action callable performs any actions other than creating a listing or + resolving a playable URL, it must return ``None``. + """ + def __init__(self, id_=''): + """ + Class constructor + """ + super(Plugin, self).__init__(id_) + self._url = 'plugin://{0}/'.format(self.id) + self._handle = None + self.actions = {} + + def __str__(self): + return ''.format(sys.argv) + + def __repr__(self): + return ''.format(sys.argv) + + @staticmethod + def get_params(paramstring): + """ + Convert a URL-encoded paramstring to a Python dict + + :param paramstring: URL-encoded paramstring + :type paramstring: str + :return: parsed paramstring + :rtype: Params + """ + raw_params = parse_qs(paramstring) + params = Params() + for key, value in raw_params.iteritems(): + params[key] = value[0] if len(value) == 1 else value + return params + + def get_url(self, plugin_url='', **kwargs): + """ + Construct a callable URL for a virtual directory item + + If plugin_url is empty, a current plugin URL is used. + kwargs are converted to a URL-encoded string of plugin call parameters + To call a plugin action, 'action' parameter must be used, + if 'action' parameter is missing, then the plugin root action is called + If the action is not added to :class:`Plugin` actions, :class:`PluginError` will be raised. + + :param plugin_url: plugin URL with trailing / (optional) + :type plugin_url: str + :param kwargs: pairs of key=value items + :return: a full plugin callback URL + :rtype: str + """ + url = plugin_url or self._url + if kwargs: + return '{0}?{1}'.format(url, urlencode(kwargs, doseq=True)) + return url + + def action(self, name=None): + """ + Action decorator + + Defines plugin callback action. If action's name is not defined explicitly, + then the action is named after the decorated function. + + .. warning:: Action's name must be unique. + + A plugin must have at least one action named ``'root'`` implicitly or explicitly. + + Example: + + .. code-block:: python + + @plugin.action() # The action is implicitly named 'root' after the decorated function + def root(params): + pass + + @plugin.action('foo') # The action name is set explicitly + def foo_action(params): + pass + + :param name: action's name (optional). + :type name: str + :raises simpleplugin.SimplePluginError: if the action with such name is already defined. + """ + def wrap(func, name=name): + if name is None: + name = func.__name__ + if name in self.actions: + raise SimplePluginError('Action "{0}" already defined!'.format(name)) + self.actions[name] = func + return func + return wrap + + def run(self, category=''): + """ + Run plugin + + :param category: str - plugin sub-category, e.g. 'Comedy'. + See :func:`xbmcplugin.setPluginCategory` for more info. + :type category: str + :raises simpleplugin.SimplePluginError: if unknown action string is provided. + """ + self._handle = int(sys.argv[1]) + if category: + xbmcplugin.setPluginCategory(self._handle, category) + params = self.get_params(sys.argv[2][1:]) + action = params.get('action', 'root') + self.log_debug(str(self)) + self.log_debug('Actions: {0}'.format(str(self.actions.keys()))) + self.log_debug('Called action "{0}" with params "{1}"'.format(action, str(params))) + try: + action_callable = self.actions[action] + except KeyError: + raise SimplePluginError('Invalid action: "{0}"!'.format(action)) + else: + result = action_callable(params) + self.log_debug('Action return value: {0}'.format(str(result))) + if isinstance(result, (list, GeneratorType)): + self._add_directory_items(self.create_listing(result)) + elif isinstance(result, basestring): + self._set_resolved_url(self.resolve_url(result)) + elif isinstance(result, tuple) and hasattr(result, 'listing'): + self._add_directory_items(result) + elif isinstance(result, tuple) and hasattr(result, 'path'): + self._set_resolved_url(result) + else: + self.log_debug('The action "{0}" has not returned any valid data to process.'.format(action)) + + @staticmethod + def create_listing(listing, succeeded=True, update_listing=False, cache_to_disk=False, sort_methods=None, + view_mode=None, content=None): + """ + Create and return a context dict for a virtual folder listing + + :param listing: the list of the plugin virtual folder items + :type listing: :class:`list` or :class:`types.GeneratorType` + :param succeeded: if ``False`` Kodi won't open a new listing and stays on the current level. + :type succeeded: bool + :param update_listing: if ``True``, Kodi won't open a sub-listing but refresh the current one. + :type update_listing: bool + :param cache_to_disk: cache this view to disk. + :type cache_to_disk: bool + :param sort_methods: the list of integer constants representing virtual folder sort methods. + :type sort_methods: tuple + :param view_mode: a numeric code for a skin view mode. + View mode codes are different in different skins except for ``50`` (basic listing). + :type view_mode: int + :param content: string - current plugin content, e.g. 'movies' or 'episodes'. + See :func:`xbmcplugin.setContent` for more info. + :type content: str + :return: context object containing necessary parameters + to create virtual folder listing in Kodi UI. + :rtype: ListContext + """ + return ListContext(listing, succeeded, update_listing, cache_to_disk, sort_methods, view_mode, content) + + @staticmethod + def resolve_url(path='', play_item=None, succeeded=True): + """ + Create and return a context dict to resolve a playable URL + + :param path: the path to a playable media. + :type path: str or unicode + :param play_item: a dict of item properties as described in the class docstring. + It allows to set additional properties for the item being played, like graphics, metadata etc. + if ``play_item`` parameter is present, then ``path`` value is ignored, and the path must be set via + ``'path'`` property of a ``play_item`` dict. + :type play_item: dict + :param succeeded: if ``False``, Kodi won't play anything + :type succeeded: bool + :return: context object containing necessary parameters + for Kodi to play the selected media. + :rtype: PlayContext + """ + return PlayContext(path, play_item, succeeded) + + @staticmethod + def create_list_item(item): + """ + Create an :class:`xbmcgui.ListItem` instance from an item dict + + :param item: a dict of ListItem properties + :type item: dict + :return: ListItem instance + :rtype: xbmcgui.ListItem + """ + list_item = xbmcgui.ListItem(label=item.get('label', ''), + label2=item.get('label2', ''), + path=item.get('path', '')) + if int(xbmc.getInfoLabel('System.BuildVersion')[:2]) >= 16: + art = item.get('art', {}) + art['thumb'] = item.get('thumb', '') + art['icon'] = item.get('icon', '') + art['fanart'] = item.get('fanart', '') + item['art'] = art + else: + list_item.setThumbnailImage(item.get('thumb', '')) + list_item.setIconImage(item.get('icon', '')) + list_item.setProperty('fanart_image', item.get('fanart', '')) + if item.get('art'): + list_item.setArt(item['art']) + if item.get('stream_info'): + for stream, stream_info in item['stream_info'].iteritems(): + list_item.addStreamInfo(stream, stream_info) + if item.get('info'): + for media, info in item['info'].iteritems(): + list_item.setInfo(media, info) + if item.get('context_menu') is not None: + list_item.addContextMenuItems(item['context_menu']) + if item.get('subtitles'): + list_item.setSubtitles(item['subtitles']) + if item.get('mime'): + list_item.setMimeType(item['mime']) + if item.get('properties'): + for key, value in item['properties'].iteritems(): + list_item.setProperty(key, value) + return list_item + + def _add_directory_items(self, context): + """ + Create a virtual folder listing + + :param context: context object + :type context: ListContext + """ + self.log_debug('Creating listing from {0}'.format(str(context))) + if context.content is not None: + xbmcplugin.setContent(self._handle, context.content) # This must be at the beginning + listing = [] + for item in context.listing: + is_folder = item.get('is_folder', True) + if item.get('list_item') is not None: + list_item = item['list_item'] + else: + list_item = self.create_list_item(item) + if item.get('is_playable'): + list_item.setProperty('IsPlayable', 'true') + is_folder = False + listing.append((item['url'], list_item, is_folder)) + xbmcplugin.addDirectoryItems(self._handle, listing, len(listing)) + if context.sort_methods is not None: + [xbmcplugin.addSortMethod(self._handle, method) for method in context.sort_methods] + xbmcplugin.endOfDirectory(self._handle, + context.succeeded, + context.update_listing, + context.cache_to_disk) + if context.view_mode is not None: + xbmc.executebuiltin('Container.SetViewMode({0})'.format(context.view_mode)) + + def _set_resolved_url(self, context): + """ + Resolve a playable URL + + :param context: context object + :type context: PlayContext + """ + self.log_debug('Resolving URL from {0}'.format(str(context))) + if context.play_item is None: + list_item = xbmcgui.ListItem(path=context.path) + else: + list_item = self.create_list_item(context.play_item) + xbmcplugin.setResolvedUrl(self._handle, context.succeeded, list_item) diff --git a/main.py b/main.py index 843fbea..5004a65 100644 --- a/main.py +++ b/main.py @@ -6,40 +6,31 @@ # Created on: 04.10.2016 # License: GPL v.3 https://www.gnu.org/copyleft/gpl.html - +import os +import xbmcaddon import xbmcgui import json -import os import shutil import dateutil.parser from datetime import datetime +# Add the /lib folder to sys +sys.path.append(xbmc.translatePath(os.path.join(xbmcaddon.Addon("plugin.audio.subsonic").getAddonInfo("path"), "lib"))) -#check for Simpleplugin. Official repos are not up to date so let's do this nasty trick. -#TO FIX : version check. https://github.com/romanvm/script.module.simpleplugin/issues/4 -try: - from simpleplugin import Plugin - from simpleplugin import Addon -except: - xbmcgui.Dialog().ok('SimplePlugin 2.0.1 required', "The Subsonic Addon requires SimplePlugin 2.0.1 framework.", "Please download and install it !", "https://github.com/romanvm/script.module.simpleplugin/releases") - sys.exit() - +import libsonic +import libsonic_extra +from simpleplugin import Plugin +from simpleplugin import Addon # Create plugin instance plugin = Plugin() -# Make sure library folder is on the path -sys.path.append(xbmc.translatePath( - os.path.join(plugin.addon.getAddonInfo('path'), 'lib'))) - # initialize_gettext #_ = plugin.initialize_gettext() connection = None cachetime = int(Addon().get_setting('cachetime')) -import libsonic_extra - def popup(text, time=5000, image=None): title = plugin.addon.getAddonInfo('name') icon = plugin.addon.getAddonInfo('icon')