├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── libsonic ├── __init__.py ├── connection.py └── errors.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | test.py 3 | MANIFEST 4 | dist 5 | build 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.0 2 | 3 | * py-sonic should now python 2/3 compatible. Unfortunately, this adds the requirement of the "six" module for this compatibility. Please file a bug if you run into issues with either python 2 or 3. 4 | * Cut over to setuptools from disttools for pip requirement support 5 | 6 | ## 0.6.2 7 | 8 | * Added an option to use GET requests, instead of the default POST requests 9 | 10 | ## 0.6.1 11 | 12 | * Added `legacyAuth` option for pre-1.13.0 support 13 | 14 | ## 0.6.0 15 | 16 | * Added API 1.14.0 support 17 | 18 | ## 0.5.1 19 | 20 | * Added the ability to use a netrc file for credentials 21 | 22 | ## 0.5.0 23 | 24 | * Added support for using credentials via a netrc file 25 | 26 | ## 0.4.1 27 | 28 | * Fixed SSL handling issues 29 | 30 | ## 0.4.0 31 | 32 | * Added missing 1.12.0 API items 33 | * Added 1.13.0 API items 34 | * 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 35 | 36 | ## 0.3.5 37 | 38 | * allow for self-signed certs 39 | 40 | ## 0.3.4 41 | 42 | * Add missing parameters to getAlbumList2 (thanks to basilfx) 43 | * Remove trailing whitespace (thanks to basilfx) 44 | 45 | ## 0.3.3 46 | 47 | * Added support for API version 1.11.0 48 | * Added a couple of additions from API version 1.10.x that were previously 49 | missed 50 | 51 | ## 0.3.1 52 | 53 | * Incorporated unofficial API calls (beallio) 54 | 55 | ## 0.2.1 56 | 57 | * Added a patch to force SSLv3 as some users were apparently having issues 58 | with the 4.7 release of Subsonic and SSL. (thanks to orangepeelbeef) 59 | 60 | ## 0.2.0 61 | 62 | * Added support for API version 1.8.0 (Subsonic verion 4.7) 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include CHANGELOG.md 3 | include README.md 4 | include requirements.txt 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # py-sonic # 2 | ## INSTALL ## 3 | 4 | Installation is fairly simple. Just do the standard install as root: 5 | 6 | tar -xvzf py-sonic-*.tar.gz 7 | cd py-sonic-* 8 | python setup.py install 9 | 10 | You can also install directly using *pip* or *easy_install* 11 | 12 | pip install py-sonic 13 | 14 | ## USAGE ## 15 | 16 | This library follows the REST API almost exactly (for now). If you follow the 17 | documentation on http://www.subsonic.org/pages/api.jsp or you do a: 18 | 19 | pydoc libsonic.connection 20 | 21 | I have also added documentation at http://stuffivelearned.org/doku.php?id=programming:python:py-sonic 22 | 23 | ## BASIC TUTORIAL ## 24 | 25 | This is about as basic as it gets. We are just going to set up the connection 26 | and then get a couple of random songs. 27 | 28 | ```python 29 | #!/usr/bin/env python 30 | 31 | from pprint import pprint 32 | import libsonic 33 | 34 | # We pass in the base url, the username, password, and port number 35 | # Be sure to use https:// if this is an ssl connection! 36 | conn = libsonic.Connection('https://music.example.com' , 'myuser' , 37 | 'secretpass' , port=443) 38 | # Let's get 2 completely random songs 39 | songs = conn.getRandomSongs(size=2) 40 | # We'll just pretty print the results we got to the terminal 41 | pprint(songs) 42 | ``` 43 | 44 | As you can see, it's really pretty simple. If you use the documentation 45 | provided in the library: 46 | 47 | pydoc libsonic.connection 48 | 49 | or the api docs on subsonic.org (listed above), you should be able to make use 50 | of your server without too much trouble. 51 | 52 | Right now, only plain old dictionary structures are returned. The plan 53 | for a later release includes the following: 54 | 55 | * Proper object representations for Artist, Album, Song, etc. 56 | * Lazy access of members (the song objects aren't created until you want to 57 | do something with them) 58 | -------------------------------------------------------------------------------- /libsonic/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of py-sonic. 3 | 4 | py-sonic is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | py-sonic is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with py-sonic. If not, see 16 | 17 | For information on method calls, see 'pydoc libsonic.connection' 18 | 19 | ---------- 20 | Basic example: 21 | ---------- 22 | 23 | import libsonic 24 | 25 | conn = libsonic.Connection('http://localhost' , 'admin' , 'password') 26 | print conn.ping() 27 | 28 | """ 29 | 30 | from .connection import * 31 | 32 | __version__ = '1.0.3' 33 | -------------------------------------------------------------------------------- /libsonic/connection.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of py-sonic. 3 | 4 | py-sonic is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | py-sonic is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with py-sonic. If not, see 16 | """ 17 | 18 | from libsonic.errors import * 19 | from netrc import netrc 20 | from hashlib import md5 21 | import urllib.request 22 | import urllib.error 23 | from http import client as http_client 24 | from urllib.parse import urlencode 25 | from io import StringIO 26 | 27 | import json 28 | import logging 29 | import socket 30 | import ssl 31 | import sys 32 | import os 33 | 34 | API_VERSION = '1.16.1' 35 | 36 | logger = logging.getLogger(__name__) 37 | 38 | class Connection(object): 39 | def __init__(self, baseUrl, username=None, password=None, port=4040, 40 | serverPath='/rest', appName='py-sonic', apiVersion=API_VERSION, 41 | insecure=False, useNetrc=None, legacyAuth=False, useGET=False, 42 | salt=None, token=None, userAgent=None): 43 | """ 44 | This will create a connection to your subsonic server 45 | 46 | baseUrl:str The base url for your server. Be sure to use 47 | "https" for SSL connections. If you are using 48 | a port other than the default 4040, be sure to 49 | specify that with the port argument. Do *not* 50 | append it here. 51 | 52 | ex: http://subsonic.example.com 53 | 54 | If you are running subsonic under a different 55 | path, specify that with the "serverPath" arg, 56 | *not* here. For example, if your subsonic 57 | lives at: 58 | 59 | https://mydomain.com:8080/path/to/subsonic/rest 60 | 61 | You would set the following: 62 | 63 | baseUrl = "https://mydomain.com" 64 | port = 8080 65 | serverPath = "/path/to/subsonic/rest" 66 | username:str The username to use for the connection. This 67 | can be None if `useNetrc' is True (and you 68 | have a valid entry in your netrc file) 69 | password:str The password to use for the connection. This 70 | can be None if `useNetrc' is True (and you 71 | have a valid entry in your netrc file) 72 | salt:str Instead of providing a password, the caller can 73 | provide both token and salt arguments for 74 | authenticaion, reducing the impact of plaintext 75 | passwords 76 | token:str Must be provided if the salt is provided. 77 | port:int The port number to connect on. The default for 78 | unencrypted subsonic connections is 4040 79 | serverPath:str The base resource path for the subsonic views. 80 | This is useful if you have your subsonic server 81 | behind a proxy and the path that you are proxying 82 | is different from the default of '/rest'. 83 | Ex: 84 | serverPath='/path/to/subs' 85 | 86 | The full url that would be built then would be 87 | (assuming defaults and using "example.com" and 88 | you are using the "ping" view): 89 | 90 | http://example.com:4040/path/to/subs/ping.view 91 | appName:str The name of your application. 92 | apiVersion:str The API version you wish to use for your 93 | application. Subsonic will throw an error if you 94 | try to use/send an api version higher than what 95 | the server supports. See the Subsonic API docs 96 | to find the Subsonic version -> API version table. 97 | This is useful if you are connecting to an older 98 | version of Subsonic. 99 | insecure:bool This will allow you to use self signed 100 | certificates when connecting if set to True. 101 | useNetrc:str|bool You can either specify a specific netrc 102 | formatted file or True to use your default 103 | netrc file ($HOME/.netrc). 104 | legacyAuth:bool Use pre-1.13.0 API version authentication 105 | useGET:bool Use a GET request instead of the default POST 106 | request. This is not recommended as request 107 | URLs can get very long with some API calls 108 | userAgent:str If specified, use this User-Agent string in 109 | the request header. If None, the default Python 110 | urllib UA will be used. 111 | """ 112 | self._baseUrl = baseUrl 113 | self._hostname = baseUrl.split('://')[1].strip() 114 | self._username = username 115 | self._rawPass = password 116 | self._salt = salt 117 | self._token = token 118 | self._legacyAuth = legacyAuth 119 | self._useGET = useGET 120 | self._userAgent = userAgent 121 | 122 | self._netrc = None 123 | if useNetrc is not None: 124 | self._process_netrc(useNetrc) 125 | elif username is None or (password is None and (salt is None or token is None)): 126 | raise CredentialError('You must specify either a username/password ' 127 | 'combination or salt/token combination or "useNetrc" must be either True or a string ' 128 | 'representing a path to a netrc file') 129 | 130 | self._port = int(port) 131 | self._apiVersion = apiVersion 132 | self._appName = appName 133 | self._serverPath = serverPath.strip('/') 134 | self._insecure = insecure 135 | self._opener = self._getOpener(self._username, self._rawPass) 136 | 137 | # Properties 138 | def setBaseUrl(self, url): 139 | self._baseUrl = url 140 | self._opener = self._getOpener(self._username, self._rawPass) 141 | baseUrl = property(lambda s: s._baseUrl, setBaseUrl) 142 | 143 | def setPort(self, port): 144 | self._port = int(port) 145 | port = property(lambda s: s._port, setPort) 146 | 147 | def setUsername(self, username): 148 | self._username = username 149 | self._opener = self._getOpener(self._username, self._rawPass) 150 | username = property(lambda s: s._username, setUsername) 151 | 152 | def setPassword(self, password): 153 | self._rawPass = password 154 | # Redo the opener with the new creds 155 | self._opener = self._getOpener(self._username, self._rawPass) 156 | password = property(lambda s: s._rawPass, setPassword) 157 | 158 | apiVersion = property(lambda s: s._apiVersion) 159 | 160 | def setAppName(self, appName): 161 | self._appName = appName 162 | appName = property(lambda s: s._appName, setAppName) 163 | 164 | def setServerPath(self, path): 165 | self._serverPath = path.strip('/') 166 | serverPath = property(lambda s: s._serverPath, setServerPath) 167 | 168 | def setInsecure(self, insecure): 169 | self._insecure = insecure 170 | insecure = property(lambda s: s._insecure, setInsecure) 171 | 172 | def setLegacyAuth(self, lauth): 173 | self._legacyAuth = lauth 174 | legacyAuth = property(lambda s: s._legacyAuth, setLegacyAuth) 175 | 176 | def setGET(self, get): 177 | self._useGET = get 178 | useGET = property(lambda s: s._useGET, setGET) 179 | 180 | # API methods 181 | def ping(self): 182 | """ 183 | since: 1.0.0 184 | 185 | Returns a boolean True if the server is alive, False otherwise 186 | """ 187 | methodName = 'ping' 188 | viewName = '%s.view' % methodName 189 | 190 | req = self._getRequest(viewName) 191 | try: 192 | res = self._doInfoReq(req) 193 | except: 194 | return False 195 | if res['status'] == 'ok': 196 | return True 197 | elif res['status'] == 'failed': 198 | exc = getExcByCode(res['error']['code']) 199 | raise exc(res['error']['message']) 200 | return False 201 | 202 | def getLicense(self): 203 | """ 204 | since: 1.0.0 205 | 206 | Gets details related to the software license 207 | 208 | Returns a dict like the following: 209 | 210 | {u'license': {u'date': u'2010-05-21T11:14:39', 211 | u'email': u'email@example.com', 212 | u'key': u'12345678901234567890123456789012', 213 | u'valid': True}, 214 | u'status': u'ok', 215 | u'version': u'1.5.0', 216 | u'xmlns': u'http://subsonic.org/restapi'} 217 | """ 218 | methodName = 'getLicense' 219 | viewName = '%s.view' % methodName 220 | 221 | req = self._getRequest(viewName) 222 | res = self._doInfoReq(req) 223 | self._checkStatus(res) 224 | return res 225 | 226 | def getScanStatus(self): 227 | """ 228 | since: 1.15.0 229 | 230 | returns the current status for media library scanning. 231 | takes no extra parameters. 232 | 233 | returns a dict like the following: 234 | 235 | {'status': 'ok', 'version': '1.15.0', 236 | 'scanstatus': {'scanning': true, 'count': 4680}} 237 | 238 | 'count' is the total number of items to be scanned 239 | """ 240 | methodName = 'getScanStatus' 241 | viewName = '%s.view' % methodName 242 | 243 | req = self._getRequest(viewName) 244 | res = self._doInfoReq(req) 245 | self._checkStatus(res) 246 | return res 247 | 248 | def startScan(self): 249 | """ 250 | since: 1.15.0 251 | 252 | Initiates a rescan of the media libraries. 253 | Takes no extra parameters. 254 | 255 | returns a dict like the following: 256 | 257 | {'status': 'ok', 'version': '1.15.0', 258 | 'scanstatus': {'scanning': true, 'count': 0}} 259 | 260 | 'scanning' changes to false when a scan is complete 261 | 'count' starts a 0 and ends at the total number of items scanned 262 | 263 | """ 264 | methodName = 'startScan' 265 | viewName = '%s.view' % methodName 266 | 267 | req = self._getRequest(viewName) 268 | res = self._doInfoReq(req) 269 | self._checkStatus(res) 270 | return res 271 | 272 | def getMusicFolders(self): 273 | """ 274 | since: 1.0.0 275 | 276 | Returns all configured music folders 277 | 278 | Returns a dict like the following: 279 | 280 | {u'musicFolders': {u'musicFolder': [{u'id': 0, u'name': u'folder1'}, 281 | {u'id': 1, u'name': u'folder2'}, 282 | {u'id': 2, u'name': u'folder3'}]}, 283 | u'status': u'ok', 284 | u'version': u'1.5.0', 285 | u'xmlns': u'http://subsonic.org/restapi'} 286 | """ 287 | methodName = 'getMusicFolders' 288 | viewName = '%s.view' % methodName 289 | 290 | req = self._getRequest(viewName) 291 | res = self._doInfoReq(req) 292 | self._checkStatus(res) 293 | return res 294 | 295 | def getNowPlaying(self): 296 | """ 297 | since: 1.0.0 298 | 299 | Returns what is currently being played by all users 300 | 301 | Returns a dict like the following: 302 | 303 | {u'nowPlaying': {u'entry': {u'album': u"Jazz 'Round Midnight 12", 304 | u'artist': u'Astrud Gilberto', 305 | u'bitRate': 172, 306 | u'contentType': u'audio/mpeg', 307 | u'coverArt': u'98349284', 308 | u'duration': 325, 309 | u'genre': u'Jazz', 310 | u'id': u'2424324', 311 | u'isDir': False, 312 | u'isVideo': False, 313 | u'minutesAgo': 0, 314 | u'parent': u'542352', 315 | u'path': u"Astrud Gilberto/Jazz 'Round Midnight 12/01 - The Girl From Ipanema.mp3", 316 | u'playerId': 1, 317 | u'size': 7004089, 318 | u'suffix': u'mp3', 319 | u'title': u'The Girl From Ipanema', 320 | u'track': 1, 321 | u'username': u'user1', 322 | u'year': 1996}}, 323 | u'status': u'ok', 324 | u'version': u'1.5.0', 325 | u'xmlns': u'http://subsonic.org/restapi'} 326 | """ 327 | methodName = 'getNowPlaying' 328 | viewName = '%s.view' % methodName 329 | 330 | req = self._getRequest(viewName) 331 | res = self._doInfoReq(req) 332 | self._checkStatus(res) 333 | return res 334 | 335 | def getIndexes(self, musicFolderId=None, ifModifiedSince=0): 336 | """ 337 | since: 1.0.0 338 | 339 | Returns an indexed structure of all artists 340 | 341 | musicFolderId:int If this is specified, it will only return 342 | artists for the given folder ID from 343 | the getMusicFolders call 344 | ifModifiedSince:int If specified, return a result if the artist 345 | collection has changed since the given 346 | unix timestamp 347 | 348 | Returns a dict like the following: 349 | 350 | {u'indexes': {u'index': [{u'artist': [{u'id': u'29834728934', 351 | u'name': u'A Perfect Circle'}, 352 | {u'id': u'238472893', 353 | u'name': u'A Small Good Thing'}, 354 | {u'id': u'9327842983', 355 | u'name': u'A Tribe Called Quest'}, 356 | {u'id': u'29348729874', 357 | u'name': u'A-Teens, The'}, 358 | {u'id': u'298472938', 359 | u'name': u'ABA STRUCTURE'}], 360 | u'lastModified': 1303318347000L}, 361 | u'status': u'ok', 362 | u'version': u'1.5.0', 363 | u'xmlns': u'http://subsonic.org/restapi'} 364 | """ 365 | methodName = 'getIndexes' 366 | viewName = '%s.view' % methodName 367 | 368 | q = self._getQueryDict({'musicFolderId': musicFolderId, 369 | 'ifModifiedSince': self._ts2milli(ifModifiedSince)}) 370 | 371 | req = self._getRequest(viewName, q) 372 | res = self._doInfoReq(req) 373 | self._checkStatus(res) 374 | self._fixLastModified(res) 375 | return res 376 | 377 | def getMusicDirectory(self, mid): 378 | """ 379 | since: 1.0.0 380 | 381 | Returns a listing of all files in a music directory. Typically used 382 | to get a list of albums for an artist or list of songs for an album. 383 | 384 | mid:str The string ID value which uniquely identifies the 385 | folder. Obtained via calls to getIndexes or 386 | getMusicDirectory. REQUIRED 387 | 388 | Returns a dict like the following: 389 | 390 | {u'directory': {u'child': [{u'artist': u'A Tribe Called Quest', 391 | u'coverArt': u'223484', 392 | u'id': u'329084', 393 | u'isDir': True, 394 | u'parent': u'234823940', 395 | u'title': u'Beats, Rhymes And Life'}, 396 | {u'artist': u'A Tribe Called Quest', 397 | u'coverArt': u'234823794', 398 | u'id': u'238472893', 399 | u'isDir': True, 400 | u'parent': u'2308472938', 401 | u'title': u'Midnight Marauders'}, 402 | {u'artist': u'A Tribe Called Quest', 403 | u'coverArt': u'39284792374', 404 | u'id': u'983274892', 405 | u'isDir': True, 406 | u'parent': u'9823749', 407 | u'title': u"People's Instinctive Travels And The Paths Of Rhythm"}, 408 | {u'artist': u'A Tribe Called Quest', 409 | u'coverArt': u'289347293', 410 | u'id': u'3894723934', 411 | u'isDir': True, 412 | u'parent': u'9832942', 413 | u'title': u'The Anthology'}, 414 | {u'artist': u'A Tribe Called Quest', 415 | u'coverArt': u'923847923', 416 | u'id': u'29834729', 417 | u'isDir': True, 418 | u'parent': u'2934872893', 419 | u'title': u'The Love Movement'}, 420 | {u'artist': u'A Tribe Called Quest', 421 | u'coverArt': u'9238742893', 422 | u'id': u'238947293', 423 | u'isDir': True, 424 | u'parent': u'9432878492', 425 | u'title': u'The Low End Theory'}], 426 | u'id': u'329847293', 427 | u'name': u'A Tribe Called Quest'}, 428 | u'status': u'ok', 429 | u'version': u'1.5.0', 430 | u'xmlns': u'http://subsonic.org/restapi'} 431 | """ 432 | methodName = 'getMusicDirectory' 433 | viewName = '%s.view' % methodName 434 | 435 | req = self._getRequest(viewName, {'id': mid}) 436 | res = self._doInfoReq(req) 437 | self._checkStatus(res) 438 | return res 439 | 440 | def search(self, artist=None, album=None, title=None, any=None, 441 | count=20, offset=0, newerThan=None): 442 | """ 443 | since: 1.0.0 444 | 445 | DEPRECATED SINCE API 1.4.0! USE search2() INSTEAD! 446 | 447 | Returns a listing of files matching the given search criteria. 448 | Supports paging with offset 449 | 450 | artist:str Search for artist 451 | album:str Search for album 452 | title:str Search for title of song 453 | any:str Search all fields 454 | count:int Max number of results to return [default: 20] 455 | offset:int Search result offset. For paging [default: 0] 456 | newerThan:int Return matches newer than this timestamp 457 | """ 458 | if artist == album == title == any == None: 459 | raise ArgumentError('Invalid search. You must supply search ' 460 | 'criteria') 461 | methodName = 'search' 462 | viewName = '%s.view' % methodName 463 | 464 | q = self._getQueryDict({'artist': artist, 'album': album, 465 | 'title': title, 'any': any, 'count': count, 'offset': offset, 466 | 'newerThan': self._ts2milli(newerThan)}) 467 | 468 | req = self._getRequest(viewName, q) 469 | res = self._doInfoReq(req) 470 | self._checkStatus(res) 471 | return res 472 | 473 | def search2(self, query, artistCount=20, artistOffset=0, albumCount=20, 474 | albumOffset=0, songCount=20, songOffset=0, musicFolderId=None): 475 | """ 476 | since: 1.4.0 477 | 478 | Returns albums, artists and songs matching the given search criteria. 479 | Supports paging through the result. 480 | 481 | query:str The search query 482 | artistCount:int Max number of artists to return [default: 20] 483 | artistOffset:int Search offset for artists (for paging) [default: 0] 484 | albumCount:int Max number of albums to return [default: 20] 485 | albumOffset:int Search offset for albums (for paging) [default: 0] 486 | songCount:int Max number of songs to return [default: 20] 487 | songOffset:int Search offset for songs (for paging) [default: 0] 488 | musicFolderId:int Only return results from the music folder 489 | with the given ID. See getMusicFolders 490 | 491 | Returns a dict like the following: 492 | 493 | {u'searchResult2': {u'album': [{u'artist': u'A Tribe Called Quest', 494 | u'coverArt': u'289347', 495 | u'id': u'32487298', 496 | u'isDir': True, 497 | u'parent': u'98374289', 498 | u'title': u'The Love Movement'}], 499 | u'artist': [{u'id': u'2947839', 500 | u'name': u'A Tribe Called Quest'}, 501 | {u'id': u'239847239', 502 | u'name': u'Tribe'}], 503 | u'song': [{u'album': u'Beats, Rhymes And Life', 504 | u'artist': u'A Tribe Called Quest', 505 | u'bitRate': 224, 506 | u'contentType': u'audio/mpeg', 507 | u'coverArt': u'329847', 508 | u'duration': 148, 509 | u'genre': u'default', 510 | u'id': u'3928472893', 511 | u'isDir': False, 512 | u'isVideo': False, 513 | u'parent': u'23984728394', 514 | u'path': u'A Tribe Called Quest/Beats, Rhymes And Life/A Tribe Called Quest - Beats, Rhymes And Life - 03 - Motivators.mp3', 515 | u'size': 4171913, 516 | u'suffix': u'mp3', 517 | u'title': u'Motivators', 518 | u'track': 3}]}, 519 | u'status': u'ok', 520 | u'version': u'1.5.0', 521 | u'xmlns': u'http://subsonic.org/restapi'} 522 | """ 523 | methodName = 'search2' 524 | viewName = '%s.view' % methodName 525 | 526 | q = self._getQueryDict({'query': query, 'artistCount': artistCount, 527 | 'artistOffset': artistOffset, 'albumCount': albumCount, 528 | 'albumOffset': albumOffset, 'songCount': songCount, 529 | 'songOffset': songOffset, 'musicFolderId': musicFolderId}) 530 | 531 | req = self._getRequest(viewName, q) 532 | res = self._doInfoReq(req) 533 | self._checkStatus(res) 534 | return res 535 | 536 | def search3(self, query, artistCount=20, artistOffset=0, albumCount=20, 537 | albumOffset=0, songCount=20, songOffset=0, musicFolderId=None): 538 | """ 539 | since: 1.8.0 540 | 541 | Works the same way as search2, but uses ID3 tags for 542 | organization 543 | 544 | query:str The search query 545 | artistCount:int Max number of artists to return [default: 20] 546 | artistOffset:int Search offset for artists (for paging) [default: 0] 547 | albumCount:int Max number of albums to return [default: 20] 548 | albumOffset:int Search offset for albums (for paging) [default: 0] 549 | songCount:int Max number of songs to return [default: 20] 550 | songOffset:int Search offset for songs (for paging) [default: 0] 551 | musicFolderId:int Only return results from the music folder 552 | with the given ID. See getMusicFolders 553 | 554 | Returns a dict like the following (search for "Tune Yards": 555 | {u'searchResult3': {u'album': [{u'artist': u'Tune-Yards', 556 | u'artistId': 1, 557 | u'coverArt': u'al-7', 558 | u'created': u'2012-01-30T12:35:33', 559 | u'duration': 3229, 560 | u'id': 7, 561 | u'name': u'Bird-Brains', 562 | u'songCount': 13}, 563 | {u'artist': u'Tune-Yards', 564 | u'artistId': 1, 565 | u'coverArt': u'al-8', 566 | u'created': u'2011-03-22T15:08:00', 567 | u'duration': 2531, 568 | u'id': 8, 569 | u'name': u'W H O K I L L', 570 | u'songCount': 10}], 571 | u'artist': {u'albumCount': 2, 572 | u'coverArt': u'ar-1', 573 | u'id': 1, 574 | u'name': u'Tune-Yards'}, 575 | u'song': [{u'album': u'Bird-Brains', 576 | u'albumId': 7, 577 | u'artist': u'Tune-Yards', 578 | u'artistId': 1, 579 | u'bitRate': 160, 580 | u'contentType': u'audio/mpeg', 581 | u'coverArt': 105, 582 | u'created': u'2012-01-30T12:35:33', 583 | u'duration': 328, 584 | u'genre': u'Lo-Fi', 585 | u'id': 107, 586 | u'isDir': False, 587 | u'isVideo': False, 588 | u'parent': 105, 589 | u'path': u'Tune Yards/Bird-Brains/10-tune-yards-fiya.mp3', 590 | u'size': 6588498, 591 | u'suffix': u'mp3', 592 | u'title': u'Fiya', 593 | u'track': 10, 594 | u'type': u'music', 595 | u'year': 2009}]}, 596 | 597 | u'status': u'ok', 598 | u'version': u'1.5.0', 599 | u'xmlns': u'http://subsonic.org/restapi'} 600 | """ 601 | methodName = 'search3' 602 | viewName = '%s.view' % methodName 603 | 604 | q = self._getQueryDict({'query': query, 'artistCount': artistCount, 605 | 'artistOffset': artistOffset, 'albumCount': albumCount, 606 | 'albumOffset': albumOffset, 'songCount': songCount, 607 | 'songOffset': songOffset, 'musicFolderId': musicFolderId}) 608 | 609 | req = self._getRequest(viewName, q) 610 | res = self._doInfoReq(req) 611 | self._checkStatus(res) 612 | return res 613 | 614 | def getPlaylists(self, username=None): 615 | """ 616 | since: 1.0.0 617 | 618 | Returns the ID and name of all saved playlists 619 | The "username" option was added in 1.8.0. 620 | 621 | username:str If specified, return playlists for this user 622 | rather than for the authenticated user. The 623 | authenticated user must have admin role 624 | if this parameter is used 625 | 626 | Returns a dict like the following: 627 | 628 | {u'playlists': {u'playlist': [{u'id': u'62656174732e6d3375', 629 | u'name': u'beats'}, 630 | {u'id': u'766172696574792e6d3375', 631 | u'name': u'variety'}]}, 632 | u'status': u'ok', 633 | u'version': u'1.5.0', 634 | u'xmlns': u'http://subsonic.org/restapi'} 635 | """ 636 | methodName = 'getPlaylists' 637 | viewName = '%s.view' % methodName 638 | 639 | q = self._getQueryDict({'username': username}) 640 | 641 | req = self._getRequest(viewName, q) 642 | res = self._doInfoReq(req) 643 | self._checkStatus(res) 644 | return res 645 | 646 | def getPlaylist(self, pid): 647 | """ 648 | since: 1.0.0 649 | 650 | Returns a listing of files in a saved playlist 651 | 652 | id:str The ID of the playlist as returned in getPlaylists() 653 | 654 | Returns a dict like the following: 655 | 656 | {u'playlist': {u'entry': {u'album': u'The Essential Bob Dylan', 657 | u'artist': u'Bob Dylan', 658 | u'bitRate': 32, 659 | u'contentType': u'audio/mpeg', 660 | u'coverArt': u'2983478293', 661 | u'duration': 984, 662 | u'genre': u'Classic Rock', 663 | u'id': u'982739428', 664 | u'isDir': False, 665 | u'isVideo': False, 666 | u'parent': u'98327428974', 667 | u'path': u"Bob Dylan/Essential Bob Dylan Disc 1/Bob Dylan - The Essential Bob Dylan - 03 - The Times They Are A-Changin'.mp3", 668 | u'size': 3921899, 669 | u'suffix': u'mp3', 670 | u'title': u"The Times They Are A-Changin'", 671 | u'track': 3}, 672 | u'id': u'44796c616e2e6d3375', 673 | u'name': u'Dylan'}, 674 | u'status': u'ok', 675 | u'version': u'1.5.0', 676 | u'xmlns': u'http://subsonic.org/restapi'} 677 | """ 678 | methodName = 'getPlaylist' 679 | viewName = '%s.view' % methodName 680 | 681 | req = self._getRequest(viewName, {'id': pid}) 682 | res = self._doInfoReq(req) 683 | self._checkStatus(res) 684 | return res 685 | 686 | def createPlaylist(self, playlistId=None, name=None, songIds=[]): 687 | """ 688 | since: 1.2.0 689 | 690 | Creates OR updates a playlist. If updating the list, the 691 | playlistId is required. If creating a list, the name is required. 692 | 693 | playlistId:str The ID of the playlist to UPDATE 694 | name:str The name of the playlist to CREATE 695 | songIds:list The list of songIds to populate the list with in 696 | either create or update mode. Note that this 697 | list will replace the existing list if updating 698 | 699 | Returns a dict like the following: 700 | 701 | {u'status': u'ok', 702 | u'version': u'1.5.0', 703 | u'xmlns': u'http://subsonic.org/restapi'} 704 | """ 705 | methodName = 'createPlaylist' 706 | viewName = '%s.view' % methodName 707 | 708 | if playlistId == name == None: 709 | raise ArgumentError('You must supply either a playlistId or a name') 710 | if playlistId is not None and name is not None: 711 | raise ArgumentError('You can only supply either a playlistId ' 712 | 'OR a name, not both') 713 | 714 | q = self._getQueryDict({'playlistId': playlistId, 'name': name}) 715 | 716 | req = self._getRequestWithList(viewName, 'songId', songIds, q) 717 | res = self._doInfoReq(req) 718 | self._checkStatus(res) 719 | return res 720 | 721 | def deletePlaylist(self, pid): 722 | """ 723 | since: 1.2.0 724 | 725 | Deletes a saved playlist 726 | 727 | pid:str ID of the playlist to delete, as obtained by getPlaylists 728 | 729 | Returns a dict like the following: 730 | 731 | """ 732 | methodName = 'deletePlaylist' 733 | viewName = '%s.view' % methodName 734 | 735 | req = self._getRequest(viewName, {'id': pid}) 736 | res = self._doInfoReq(req) 737 | self._checkStatus(res) 738 | return res 739 | 740 | def download(self, sid): 741 | """ 742 | since: 1.0.0 743 | 744 | Downloads a given music file. 745 | 746 | sid:str The ID of the music file to download. 747 | 748 | Returns the file-like object for reading or raises an exception 749 | on error 750 | """ 751 | methodName = 'download' 752 | viewName = '%s.view' % methodName 753 | 754 | req = self._getRequest(viewName, {'id': sid}) 755 | res = self._doBinReq(req) 756 | if isinstance(res, dict): 757 | self._checkStatus(res) 758 | return res 759 | 760 | def stream(self, sid, maxBitRate=0, tformat=None, timeOffset=None, 761 | size=None, estimateContentLength=False, converted=False): 762 | """ 763 | since: 1.0.0 764 | 765 | Downloads a given music file. 766 | 767 | sid:str The ID of the music file to download. 768 | maxBitRate:int (since: 1.2.0) If specified, the server will 769 | attempt to limit the bitrate to this value, in 770 | kilobits per second. If set to zero (default), no limit 771 | is imposed. Legal values are: 0, 32, 40, 48, 56, 64, 772 | 80, 96, 112, 128, 160, 192, 224, 256 and 320. 773 | tformat:str (since: 1.6.0) Specifies the target format 774 | (e.g. "mp3" or "flv") in case there are multiple 775 | applicable transcodings (since: 1.9.0) You can use 776 | the special value "raw" to disable transcoding 777 | timeOffset:int (since: 1.6.0) Only applicable to video 778 | streaming. Start the stream at the given 779 | offset (in seconds) into the video 780 | size:str (since: 1.6.0) The requested video size in 781 | WxH, for instance 640x480 782 | estimateContentLength:bool (since: 1.8.0) If set to True, 783 | the HTTP Content-Length header 784 | will be set to an estimated 785 | value for trancoded media 786 | converted:bool (since: 1.14.0) Only applicable to video streaming. 787 | Subsonic can optimize videos for streaming by 788 | converting them to MP4. If a conversion exists for 789 | the video in question, then setting this parameter 790 | to "true" will cause the converted video to be 791 | returned instead of the original. 792 | 793 | Returns the file-like object for reading or raises an exception 794 | on error 795 | """ 796 | methodName = 'stream' 797 | viewName = '%s.view' % methodName 798 | 799 | q = self._getQueryDict({'id': sid, 'maxBitRate': maxBitRate, 800 | 'format': tformat, 'timeOffset': timeOffset, 'size': size, 801 | 'estimateContentLength': estimateContentLength, 802 | 'converted': converted}) 803 | 804 | req = self._getRequest(viewName, q) 805 | res = self._doBinReq(req) 806 | if isinstance(res, dict): 807 | self._checkStatus(res) 808 | return res 809 | 810 | def getCoverArt(self, aid, size=None): 811 | """ 812 | since: 1.0.0 813 | 814 | Returns a cover art image 815 | 816 | aid:str ID string for the cover art image to download 817 | size:int If specified, scale image to this size 818 | 819 | Returns the file-like object for reading or raises an exception 820 | on error 821 | """ 822 | methodName = 'getCoverArt' 823 | viewName = '%s.view' % methodName 824 | 825 | q = self._getQueryDict({'id': aid, 'size': size}) 826 | 827 | req = self._getRequest(viewName, q) 828 | res = self._doBinReq(req) 829 | if isinstance(res, dict): 830 | self._checkStatus(res) 831 | return res 832 | 833 | def scrobble(self, sid, submission=True, listenTime=None): 834 | """ 835 | since: 1.5.0 836 | 837 | "Scrobbles" a given music file on last.fm. Requires that the user 838 | has set this up. 839 | 840 | Since 1.8.0 you may specify multiple id (and optionally time) 841 | parameters to scrobble multiple files. 842 | 843 | Since 1.11.0 this method will also update the play count and 844 | last played timestamp for the song and album. It will also make 845 | the song appear in the "Now playing" page in the web app, and 846 | appear in the list of songs returned by getNowPlaying 847 | 848 | sid:str The ID of the file to scrobble 849 | submission:bool Whether this is a "submission" or a "now playing" 850 | notification 851 | listenTime:int (Since 1.8.0) The time (unix timestamp) at 852 | which the song was listened to. 853 | 854 | Returns a dict like the following: 855 | 856 | {u'status': u'ok', 857 | u'version': u'1.5.0', 858 | u'xmlns': u'http://subsonic.org/restapi'} 859 | """ 860 | methodName = 'scrobble' 861 | viewName = '%s.view' % methodName 862 | 863 | q = self._getQueryDict({'id': sid, 'submission': submission, 864 | 'time': self._ts2milli(listenTime)}) 865 | 866 | req = self._getRequest(viewName, q) 867 | res = self._doInfoReq(req) 868 | self._checkStatus(res) 869 | return res 870 | 871 | def changePassword(self, username, password): 872 | """ 873 | since: 1.1.0 874 | 875 | Changes the password of an existing Subsonic user. Note that the 876 | user performing this must have admin privileges 877 | 878 | username:str The username whose password is being changed 879 | password:str The new password of the user 880 | 881 | Returns a dict like the following: 882 | 883 | {u'status': u'ok', 884 | u'version': u'1.5.0', 885 | u'xmlns': u'http://subsonic.org/restapi'} 886 | """ 887 | methodName = 'changePassword' 888 | viewName = '%s.view' % methodName 889 | hexPass = 'enc:%s' % self._hexEnc(password) 890 | 891 | # There seems to be an issue with some subsonic implementations 892 | # not recognizing the "enc:" precursor to the encoded password and 893 | # encodes the whole "enc:" as the password. Weird. 894 | #q = {'username': username, 'password': hexPass.lower()} 895 | q = {'username': username, 'password': password} 896 | 897 | req = self._getRequest(viewName, q) 898 | res = self._doInfoReq(req) 899 | self._checkStatus(res) 900 | return res 901 | 902 | def getUser(self, username): 903 | """ 904 | since: 1.3.0 905 | 906 | Get details about a given user, including which auth roles it has. 907 | Can be used to enable/disable certain features in the client, such 908 | as jukebox control 909 | 910 | username:str The username to retrieve. You can only retrieve 911 | your own user unless you have admin privs. 912 | 913 | Returns a dict like the following: 914 | 915 | {u'status': u'ok', 916 | u'user': {u'adminRole': False, 917 | u'commentRole': False, 918 | u'coverArtRole': False, 919 | u'downloadRole': True, 920 | u'jukeboxRole': False, 921 | u'playlistRole': True, 922 | u'podcastRole': False, 923 | u'settingsRole': True, 924 | u'streamRole': True, 925 | u'uploadRole': True, 926 | u'username': u'test'}, 927 | u'version': u'1.5.0', 928 | u'xmlns': u'http://subsonic.org/restapi'} 929 | """ 930 | methodName = 'getUser' 931 | viewName = '%s.view' % methodName 932 | 933 | q = {'username': username} 934 | 935 | req = self._getRequest(viewName, q) 936 | res = self._doInfoReq(req) 937 | self._checkStatus(res) 938 | return res 939 | 940 | def getUsers(self): 941 | """ 942 | since 1.8.0 943 | 944 | Gets a list of users 945 | 946 | returns a dict like the following 947 | 948 | {u'status': u'ok', 949 | u'users': {u'user': [{u'adminRole': True, 950 | u'commentRole': True, 951 | u'coverArtRole': True, 952 | u'downloadRole': True, 953 | u'jukeboxRole': True, 954 | u'playlistRole': True, 955 | u'podcastRole': True, 956 | u'scrobblingEnabled': True, 957 | u'settingsRole': True, 958 | u'shareRole': True, 959 | u'streamRole': True, 960 | u'uploadRole': True, 961 | u'username': u'user1'}, 962 | ... 963 | ... 964 | ]}, 965 | u'version': u'1.10.2', 966 | u'xmlns': u'http://subsonic.org/restapi'} 967 | """ 968 | methodName = 'getUsers' 969 | viewName = '%s.view' % methodName 970 | 971 | req = self._getRequest(viewName) 972 | res = self._doInfoReq(req) 973 | self._checkStatus(res) 974 | return res 975 | 976 | def createUser(self, username, password, email, 977 | ldapAuthenticated=False, adminRole=False, settingsRole=True, 978 | streamRole=True, jukeboxRole=False, downloadRole=False, 979 | uploadRole=False, playlistRole=False, coverArtRole=False, 980 | commentRole=False, podcastRole=False, shareRole=False, 981 | videoConversionRole=False, musicFolderId=None): 982 | """ 983 | since: 1.1.0 984 | 985 | Creates a new subsonic user, using the parameters defined. See the 986 | documentation at http://subsonic.org for more info on all the roles. 987 | 988 | username:str The username of the new user 989 | password:str The password for the new user 990 | email:str The email of the new user 991 | 992 | musicFolderId:int These are the only folders the user has access to 993 | 994 | Returns a dict like the following: 995 | 996 | {u'status': u'ok', 997 | u'version': u'1.5.0', 998 | u'xmlns': u'http://subsonic.org/restapi'} 999 | """ 1000 | methodName = 'createUser' 1001 | viewName = '%s.view' % methodName 1002 | hexPass = 'enc:%s' % self._hexEnc(password) 1003 | 1004 | q = self._getQueryDict({ 1005 | 'username': username, 'password': hexPass, 'email': email, 1006 | 'ldapAuthenticated': ldapAuthenticated, 'adminRole': adminRole, 1007 | 'settingsRole': settingsRole, 'streamRole': streamRole, 1008 | 'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole, 1009 | 'uploadRole': uploadRole, 'playlistRole': playlistRole, 1010 | 'coverArtRole': coverArtRole, 'commentRole': commentRole, 1011 | 'podcastRole': podcastRole, 'shareRole': shareRole, 1012 | 'videoConversionRole': videoConversionRole, 1013 | 'musicFolderId': musicFolderId 1014 | }) 1015 | 1016 | req = self._getRequest(viewName, q) 1017 | res = self._doInfoReq(req) 1018 | self._checkStatus(res) 1019 | return res 1020 | 1021 | def updateUser(self, username, password=None, email=None, 1022 | ldapAuthenticated=False, adminRole=False, settingsRole=True, 1023 | streamRole=True, jukeboxRole=False, downloadRole=False, 1024 | uploadRole=False, playlistRole=False, coverArtRole=False, 1025 | commentRole=False, podcastRole=False, shareRole=False, 1026 | videoConversionRole=False, musicFolderId=None, maxBitRate=0): 1027 | """ 1028 | since 1.10.1 1029 | 1030 | Modifies an existing Subsonic user. 1031 | 1032 | username:str The username of the user to update. 1033 | musicFolderId:int Only return results from the music folder 1034 | with the given ID. See getMusicFolders 1035 | maxBitRate:int The max bitrate for the user. 0 is unlimited 1036 | 1037 | All other args are the same as create user and you can update 1038 | whatever item you wish to update for the given username. 1039 | 1040 | Returns a dict like the following: 1041 | 1042 | {u'status': u'ok', 1043 | u'version': u'1.5.0', 1044 | u'xmlns': u'http://subsonic.org/restapi'} 1045 | """ 1046 | methodName = 'updateUser' 1047 | viewName = '%s.view' % methodName 1048 | if password is not None: 1049 | password = 'enc:%s' % self._hexEnc(password) 1050 | q = self._getQueryDict({'username': username, 'password': password, 1051 | 'email': email, 'ldapAuthenticated': ldapAuthenticated, 1052 | 'adminRole': adminRole, 1053 | 'settingsRole': settingsRole, 'streamRole': streamRole, 1054 | 'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole, 1055 | 'uploadRole': uploadRole, 'playlistRole': playlistRole, 1056 | 'coverArtRole': coverArtRole, 'commentRole': commentRole, 1057 | 'podcastRole': podcastRole, 'shareRole': shareRole, 1058 | 'videoConversionRole': videoConversionRole, 1059 | 'musicFolderId': musicFolderId, 'maxBitRate': maxBitRate 1060 | }) 1061 | req = self._getRequest(viewName, q) 1062 | res = self._doInfoReq(req) 1063 | self._checkStatus(res) 1064 | return res 1065 | 1066 | def deleteUser(self, username): 1067 | """ 1068 | since: 1.3.0 1069 | 1070 | Deletes an existing Subsonic user. Of course, you must have admin 1071 | rights for this. 1072 | 1073 | username:str The username of the user to delete 1074 | 1075 | Returns a dict like the following: 1076 | 1077 | {u'status': u'ok', 1078 | u'version': u'1.5.0', 1079 | u'xmlns': u'http://subsonic.org/restapi'} 1080 | """ 1081 | methodName = 'deleteUser' 1082 | viewName = '%s.view' % methodName 1083 | 1084 | q = {'username': username} 1085 | 1086 | req = self._getRequest(viewName, q) 1087 | res = self._doInfoReq(req) 1088 | self._checkStatus(res) 1089 | return res 1090 | 1091 | def getChatMessages(self, since=1): 1092 | """ 1093 | since: 1.2.0 1094 | 1095 | Returns the current visible (non-expired) chat messages. 1096 | 1097 | since:int Only return messages newer than this timestamp 1098 | 1099 | NOTE: All times returned are in MILLISECONDS since the Epoch, not 1100 | seconds! 1101 | 1102 | Returns a dict like the following: 1103 | {u'chatMessages': {u'chatMessage': {u'message': u'testing 123', 1104 | u'time': 1303411919872L, 1105 | u'username': u'admin'}}, 1106 | u'status': u'ok', 1107 | u'version': u'1.5.0', 1108 | u'xmlns': u'http://subsonic.org/restapi'} 1109 | """ 1110 | methodName = 'getChatMessages' 1111 | viewName = '%s.view' % methodName 1112 | 1113 | q = {'since': self._ts2milli(since)} 1114 | 1115 | req = self._getRequest(viewName, q) 1116 | res = self._doInfoReq(req) 1117 | self._checkStatus(res) 1118 | return res 1119 | 1120 | def addChatMessage(self, message): 1121 | """ 1122 | since: 1.2.0 1123 | 1124 | Adds a message to the chat log 1125 | 1126 | message:str The message to add 1127 | 1128 | Returns a dict like the following: 1129 | 1130 | {u'status': u'ok', 1131 | u'version': u'1.5.0', 1132 | u'xmlns': u'http://subsonic.org/restapi'} 1133 | """ 1134 | methodName = 'addChatMessage' 1135 | viewName = '%s.view' % methodName 1136 | 1137 | q = {'message': message} 1138 | 1139 | req = self._getRequest(viewName, q) 1140 | res = self._doInfoReq(req) 1141 | self._checkStatus(res) 1142 | return res 1143 | 1144 | def getAlbumList(self, ltype, size=10, offset=0, fromYear=None, 1145 | toYear=None, genre=None, musicFolderId=None): 1146 | """ 1147 | since: 1.2.0 1148 | 1149 | Returns a list of random, newest, highest rated etc. albums. 1150 | Similar to the album lists on the home page of the Subsonic 1151 | web interface 1152 | 1153 | ltype:str The list type. Must be one of the following: random, 1154 | newest, highest, frequent, recent, 1155 | (since 1.8.0 -> )starred, alphabeticalByName, 1156 | alphabeticalByArtist 1157 | Since 1.10.1 you can use byYear and byGenre to 1158 | list albums in a given year range or genre. 1159 | size:int The number of albums to return. Max 500 1160 | offset:int The list offset. Use for paging. Max 5000 1161 | fromYear:int If you specify the ltype as "byYear", you *must* 1162 | specify fromYear 1163 | toYear:int If you specify the ltype as "byYear", you *must* 1164 | specify toYear 1165 | genre:str The name of the genre e.g. "Rock". You must specify 1166 | genre if you set the ltype to "byGenre" 1167 | musicFolderId:str Only return albums in the music folder with 1168 | the given ID. See getMusicFolders() 1169 | 1170 | Returns a dict like the following: 1171 | 1172 | {u'albumList': {u'album': [{u'artist': u'Hank Williams', 1173 | u'id': u'3264928374', 1174 | u'isDir': True, 1175 | u'parent': u'9238479283', 1176 | u'title': u'The Original Singles Collection...Plus'}, 1177 | {u'artist': u'Freundeskreis', 1178 | u'coverArt': u'9823749823', 1179 | u'id': u'23492834', 1180 | u'isDir': True, 1181 | u'parent': u'9827492374', 1182 | u'title': u'Quadratur des Kreises'}]}, 1183 | u'status': u'ok', 1184 | u'version': u'1.5.0', 1185 | u'xmlns': u'http://subsonic.org/restapi'} 1186 | """ 1187 | methodName = 'getAlbumList' 1188 | viewName = '%s.view' % methodName 1189 | 1190 | q = self._getQueryDict({'type': ltype, 'size': size, 1191 | 'offset': offset, 'fromYear': fromYear, 'toYear': toYear, 1192 | 'genre': genre, 'musicFolderId': musicFolderId}) 1193 | 1194 | req = self._getRequest(viewName, q) 1195 | res = self._doInfoReq(req) 1196 | self._checkStatus(res) 1197 | return res 1198 | 1199 | def getAlbumList2(self, ltype, size=10, offset=0, fromYear=None, 1200 | toYear=None, genre=None): 1201 | """ 1202 | since 1.8.0 1203 | 1204 | Returns a list of random, newest, highest rated etc. albums. 1205 | This is similar to getAlbumList, but uses ID3 tags for 1206 | organization 1207 | 1208 | ltype:str The list type. Must be one of the following: random, 1209 | newest, highest, frequent, recent, 1210 | (since 1.8.0 -> )starred, alphabeticalByName, 1211 | alphabeticalByArtist 1212 | Since 1.10.1 you can use byYear and byGenre to 1213 | list albums in a given year range or genre. 1214 | size:int The number of albums to return. Max 500 1215 | offset:int The list offset. Use for paging. Max 5000 1216 | fromYear:int If you specify the ltype as "byYear", you *must* 1217 | specify fromYear 1218 | toYear:int If you specify the ltype as "byYear", you *must* 1219 | specify toYear 1220 | genre:str The name of the genre e.g. "Rock". You must specify 1221 | genre if you set the ltype to "byGenre" 1222 | 1223 | Returns a dict like the following: 1224 | {u'albumList2': {u'album': [{u'artist': u'Massive Attack', 1225 | u'artistId': 0, 1226 | u'coverArt': u'al-0', 1227 | u'created': u'2009-08-28T10:00:44', 1228 | u'duration': 3762, 1229 | u'id': 0, 1230 | u'name': u'100th Window', 1231 | u'songCount': 9}, 1232 | {u'artist': u'Massive Attack', 1233 | u'artistId': 0, 1234 | u'coverArt': u'al-5', 1235 | u'created': u'2003-11-03T22:00:00', 1236 | u'duration': 2715, 1237 | u'id': 5, 1238 | u'name': u'Blue Lines', 1239 | u'songCount': 9}]}, 1240 | u'status': u'ok', 1241 | u'version': u'1.8.0', 1242 | u'xmlns': u'http://subsonic.org/restapi'} 1243 | """ 1244 | methodName = 'getAlbumList2' 1245 | viewName = '%s.view' % methodName 1246 | 1247 | q = self._getQueryDict({'type': ltype, 'size': size, 1248 | 'offset': offset, 'fromYear': fromYear, 'toYear': toYear, 1249 | 'genre': genre}) 1250 | 1251 | req = self._getRequest(viewName, q) 1252 | res = self._doInfoReq(req) 1253 | self._checkStatus(res) 1254 | return res 1255 | 1256 | def getRandomSongs(self, size=10, genre=None, fromYear=None, 1257 | toYear=None, musicFolderId=None): 1258 | """ 1259 | since 1.2.0 1260 | 1261 | Returns random songs matching the given criteria 1262 | 1263 | size:int The max number of songs to return. Max 500 1264 | genre:str Only return songs from this genre 1265 | fromYear:int Only return songs after or in this year 1266 | toYear:int Only return songs before or in this year 1267 | musicFolderId:str Only return songs in the music folder with the 1268 | given ID. See getMusicFolders 1269 | 1270 | Returns a dict like the following: 1271 | 1272 | {u'randomSongs': {u'song': [{u'album': u'1998 EP - Airbag (How Am I Driving)', 1273 | u'artist': u'Radiohead', 1274 | u'bitRate': 320, 1275 | u'contentType': u'audio/mpeg', 1276 | u'duration': 129, 1277 | u'id': u'9284728934', 1278 | u'isDir': False, 1279 | u'isVideo': False, 1280 | u'parent': u'983249823', 1281 | u'path': u'Radiohead/1998 EP - Airbag (How Am I Driving)/06 - Melatonin.mp3', 1282 | u'size': 5177469, 1283 | u'suffix': u'mp3', 1284 | u'title': u'Melatonin'}, 1285 | {u'album': u'Mezmerize', 1286 | u'artist': u'System Of A Down', 1287 | u'bitRate': 214, 1288 | u'contentType': u'audio/mpeg', 1289 | u'coverArt': u'23849372894', 1290 | u'duration': 176, 1291 | u'id': u'28937492834', 1292 | u'isDir': False, 1293 | u'isVideo': False, 1294 | u'parent': u'92837492837', 1295 | u'path': u'System Of A Down/Mesmerize/10 - System Of A Down - Old School Hollywood.mp3', 1296 | u'size': 4751360, 1297 | u'suffix': u'mp3', 1298 | u'title': u'Old School Hollywood', 1299 | u'track': 10}]}, 1300 | u'status': u'ok', 1301 | u'version': u'1.5.0', 1302 | u'xmlns': u'http://subsonic.org/restapi'} 1303 | """ 1304 | methodName = 'getRandomSongs' 1305 | viewName = '%s.view' % methodName 1306 | 1307 | q = self._getQueryDict({'size': size, 'genre': genre, 1308 | 'fromYear': fromYear, 'toYear': toYear, 1309 | 'musicFolderId': musicFolderId}) 1310 | 1311 | req = self._getRequest(viewName, q) 1312 | res = self._doInfoReq(req) 1313 | self._checkStatus(res) 1314 | return res 1315 | 1316 | def getLyrics(self, artist=None, title=None): 1317 | """ 1318 | since: 1.2.0 1319 | 1320 | Searches for and returns lyrics for a given song 1321 | 1322 | artist:str The artist name 1323 | title:str The song title 1324 | 1325 | Returns a dict like the following for 1326 | getLyrics('Bob Dylan', 'Blowin in the wind'): 1327 | 1328 | {u'lyrics': {u'artist': u'Bob Dylan', 1329 | u'content': u"How many roads must a man walk down", 1330 | u'title': u"Blowin' in the Wind"}, 1331 | u'status': u'ok', 1332 | u'version': u'1.5.0', 1333 | u'xmlns': u'http://subsonic.org/restapi'} 1334 | """ 1335 | methodName = 'getLyrics' 1336 | viewName = '%s.view' % methodName 1337 | 1338 | q = self._getQueryDict({'artist': artist, 'title': title}) 1339 | 1340 | req = self._getRequest(viewName, q) 1341 | res = self._doInfoReq(req) 1342 | self._checkStatus(res) 1343 | return res 1344 | 1345 | def jukeboxControl(self, action, index=None, sids=[], gain=None, 1346 | offset=None): 1347 | """ 1348 | since: 1.2.0 1349 | 1350 | NOTE: Some options were added as of API version 1.7.0 1351 | 1352 | Controls the jukebox, i.e., playback directly on the server's 1353 | audio hardware. Note: The user must be authorized to control 1354 | the jukebox 1355 | 1356 | action:str The operation to perform. Must be one of: get, 1357 | start, stop, skip, add, clear, remove, shuffle, 1358 | setGain, status (added in API 1.7.0), 1359 | set (added in API 1.7.0) 1360 | index:int Used by skip and remove. Zero-based index of the 1361 | song to skip to or remove. 1362 | sids:str Used by "add" and "set". ID of song to add to the 1363 | jukebox playlist. Use multiple id parameters to 1364 | add many songs in the same request. Whether you 1365 | are passing one song or many into this, this 1366 | parameter MUST be a list 1367 | gain:float Used by setGain to control the playback volume. 1368 | A float value between 0.0 and 1.0 1369 | offset:int (added in API 1.7.0) Used by "skip". Start playing 1370 | this many seconds into the track. 1371 | """ 1372 | methodName = 'jukeboxControl' 1373 | viewName = '%s.view' % methodName 1374 | 1375 | q = self._getQueryDict({'action': action, 'index': index, 1376 | 'gain': gain, 'offset': offset}) 1377 | 1378 | req = None 1379 | if action == 'add': 1380 | # We have to deal with the sids 1381 | if not (isinstance(sids, list) or isinstance(sids, tuple)): 1382 | raise ArgumentError('If you are adding songs, "sids" must ' 1383 | 'be a list or tuple!') 1384 | req = self._getRequestWithList(viewName, 'id', sids, q) 1385 | else: 1386 | req = self._getRequest(viewName, q) 1387 | res = self._doInfoReq(req) 1388 | self._checkStatus(res) 1389 | return res 1390 | 1391 | def getPodcasts(self, incEpisodes=True, pid=None): 1392 | """ 1393 | since: 1.6.0 1394 | 1395 | Returns all podcast channels the server subscribes to and their 1396 | episodes. 1397 | 1398 | incEpisodes:bool (since: 1.9.0) Whether to include Podcast 1399 | episodes in the returned result. 1400 | pid:str (since: 1.9.0) If specified, only return 1401 | the Podcast channel with this ID. 1402 | 1403 | Returns a dict like the following: 1404 | {u'status': u'ok', 1405 | u'version': u'1.6.0', 1406 | u'xmlns': u'http://subsonic.org/restapi', 1407 | u'podcasts': {u'channel': {u'description': u"Dr Chris Smith...", 1408 | u'episode': [{u'album': u'Dr Karl and the Naked Scientist', 1409 | u'artist': u'BBC Radio 5 live', 1410 | u'bitRate': 64, 1411 | u'contentType': u'audio/mpeg', 1412 | u'coverArt': u'2f6f7074', 1413 | u'description': u'Dr Karl answers all your science related questions.', 1414 | u'duration': 2902, 1415 | u'genre': u'Podcast', 1416 | u'id': 0, 1417 | u'isDir': False, 1418 | u'isVideo': False, 1419 | u'parent': u'2f6f70742f737562736f6e69632f706f6463617374732f4472204b61726c20616e6420746865204e616b656420536369656e74697374', 1420 | u'publishDate': u'2011-08-17 22:06:00.0', 1421 | u'size': 23313059, 1422 | u'status': u'completed', 1423 | u'streamId': u'2f6f70742f737562736f6e69632f706f6463617374732f4472204b61726c20616e6420746865204e616b656420536369656e746973742f64726b61726c5f32303131303831382d30343036612e6d7033', 1424 | u'suffix': u'mp3', 1425 | u'title': u'DrKarl: Peppermints, Chillies & Receptors', 1426 | u'year': 2011}, 1427 | {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!', 1428 | u'id': 1, 1429 | u'publishDate': u'2011-08-14 21:05:00.0', 1430 | u'status': u'skipped', 1431 | u'title': u'DrKarl: how many bubbles in your bath? 15 AUG 11'}, 1432 | ... 1433 | {u'description': u'Dr Karl joins Rhod to answer all your science questions', 1434 | u'id': 9, 1435 | u'publishDate': u'2011-07-06 22:12:00.0', 1436 | u'status': u'skipped', 1437 | u'title': u'DrKarl: 8 Jul 11 The Strange Sound of the MRI Scanner'}], 1438 | u'id': 0, 1439 | u'status': u'completed', 1440 | u'title': u'Dr Karl and the Naked Scientist', 1441 | u'url': u'http://downloads.bbc.co.uk/podcasts/fivelive/drkarl/rss.xml'}} 1442 | } 1443 | 1444 | See also: http://subsonic.svn.sourceforge.net/viewvc/subsonic/trunk/subsonic-main/src/main/webapp/xsd/podcasts_example_1.xml?view=markup 1445 | """ 1446 | methodName = 'getPodcasts' 1447 | viewName = '%s.view' % methodName 1448 | 1449 | q = self._getQueryDict({'includeEpisodes': incEpisodes, 1450 | 'id': pid}) 1451 | req = self._getRequest(viewName, q) 1452 | res = self._doInfoReq(req) 1453 | self._checkStatus(res) 1454 | return res 1455 | 1456 | def getShares(self): 1457 | """ 1458 | since: 1.6.0 1459 | 1460 | Returns information about shared media this user is allowed to manage 1461 | 1462 | Note that entry can be either a single dict or a list of dicts 1463 | 1464 | Returns a dict like the following: 1465 | 1466 | {u'status': u'ok', 1467 | u'version': u'1.6.0', 1468 | u'xmlns': u'http://subsonic.org/restapi', 1469 | u'shares': {u'share': [ 1470 | {u'created': u'2011-08-18T10:01:35', 1471 | u'entry': {u'artist': u'Alice In Chains', 1472 | u'coverArt': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e732f416c69636520496e20436861696e732f636f7665722e6a7067', 1473 | u'id': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e732f416c69636520496e20436861696e73', 1474 | u'isDir': True, 1475 | u'parent': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e73', 1476 | u'title': u'Alice In Chains'}, 1477 | u'expires': u'2012-08-18T10:01:35', 1478 | u'id': 0, 1479 | u'url': u'http://crustymonkey.subsonic.org/share/BuLbF', 1480 | u'username': u'admin', 1481 | u'visitCount': 0 1482 | }]} 1483 | } 1484 | """ 1485 | methodName = 'getShares' 1486 | viewName = '%s.view' % methodName 1487 | 1488 | req = self._getRequest(viewName) 1489 | res = self._doInfoReq(req) 1490 | self._checkStatus(res) 1491 | return res 1492 | 1493 | def createShare(self, shids=[], description=None, expires=None): 1494 | """ 1495 | since: 1.6.0 1496 | 1497 | Creates a public URL that can be used by anyone to stream music 1498 | or video from the Subsonic server. The URL is short and suitable 1499 | for posting on Facebook, Twitter etc. Note: The user must be 1500 | authorized to share (see Settings > Users > User is allowed to 1501 | share files with anyone). 1502 | 1503 | shids:list[str] A list of ids of songs, albums or videos 1504 | to share. 1505 | description:str A description that will be displayed to 1506 | people visiting the shared media 1507 | (optional). 1508 | expires:float A timestamp pertaining to the time at 1509 | which this should expire (optional) 1510 | 1511 | This returns a structure like you would get back from getShares() 1512 | containing just your new share. 1513 | """ 1514 | methodName = 'createShare' 1515 | viewName = '%s.view' % methodName 1516 | 1517 | q = self._getQueryDict({'description': description, 1518 | 'expires': self._ts2milli(expires)}) 1519 | req = self._getRequestWithList(viewName, 'id', shids, q) 1520 | res = self._doInfoReq(req) 1521 | self._checkStatus(res) 1522 | return res 1523 | 1524 | def updateShare(self, shid, description=None, expires=None): 1525 | """ 1526 | since: 1.6.0 1527 | 1528 | Updates the description and/or expiration date for an existing share 1529 | 1530 | shid:str The id of the share to update 1531 | description:str The new description for the share (optional). 1532 | expires:float The new timestamp for the expiration time of this 1533 | share (optional). 1534 | """ 1535 | methodName = 'updateShare' 1536 | viewName = '%s.view' % methodName 1537 | 1538 | q = self._getQueryDict({'id': shid, 'description': description, 1539 | expires: self._ts2milli(expires)}) 1540 | 1541 | req = self._getRequest(viewName, q) 1542 | res = self._doInfoReq(req) 1543 | self._checkStatus(res) 1544 | return res 1545 | 1546 | def deleteShare(self, shid): 1547 | """ 1548 | since: 1.6.0 1549 | 1550 | Deletes an existing share 1551 | 1552 | shid:str The id of the share to delete 1553 | 1554 | Returns a standard response dict 1555 | """ 1556 | methodName = 'deleteShare' 1557 | viewName = '%s.view' % methodName 1558 | 1559 | q = self._getQueryDict({'id': shid}) 1560 | 1561 | req = self._getRequest(viewName, q) 1562 | res = self._doInfoReq(req) 1563 | self._checkStatus(res) 1564 | return res 1565 | 1566 | def setRating(self, id, rating): 1567 | """ 1568 | since: 1.6.0 1569 | 1570 | Sets the rating for a music file 1571 | 1572 | id:str The id of the item (song/artist/album) to rate 1573 | rating:int The rating between 1 and 5 (inclusive), or 0 to remove 1574 | the rating 1575 | 1576 | Returns a standard response dict 1577 | """ 1578 | methodName = 'setRating' 1579 | viewName = '%s.view' % methodName 1580 | 1581 | try: 1582 | rating = int(rating) 1583 | except: 1584 | raise ArgumentError('Rating must be an integer between 0 and 5: ' 1585 | '%r' % rating) 1586 | if rating < 0 or rating > 5: 1587 | raise ArgumentError('Rating must be an integer between 0 and 5: ' 1588 | '%r' % rating) 1589 | 1590 | q = self._getQueryDict({'id': id, 'rating': rating}) 1591 | 1592 | req = self._getRequest(viewName, q) 1593 | res = self._doInfoReq(req) 1594 | self._checkStatus(res) 1595 | return res 1596 | 1597 | def getArtists(self): 1598 | """ 1599 | since 1.8.0 1600 | 1601 | Similar to getIndexes(), but this method uses the ID3 tags to 1602 | determine the artist 1603 | 1604 | Returns a dict like the following: 1605 | {u'artists': {u'index': [{u'artist': {u'albumCount': 7, 1606 | u'coverArt': u'ar-0', 1607 | u'id': 0, 1608 | u'name': u'Massive Attack'}, 1609 | u'name': u'M'}, 1610 | {u'artist': {u'albumCount': 2, 1611 | u'coverArt': u'ar-1', 1612 | u'id': 1, 1613 | u'name': u'Tune-Yards'}, 1614 | u'name': u'T'}]}, 1615 | u'status': u'ok', 1616 | u'version': u'1.8.0', 1617 | u'xmlns': u'http://subsonic.org/restapi'} 1618 | """ 1619 | methodName = 'getArtists' 1620 | viewName = '%s.view' % methodName 1621 | 1622 | req = self._getRequest(viewName) 1623 | res = self._doInfoReq(req) 1624 | self._checkStatus(res) 1625 | return res 1626 | 1627 | def getArtist(self, id): 1628 | """ 1629 | since 1.8.0 1630 | 1631 | Returns the info (albums) for an artist. This method uses 1632 | the ID3 tags for organization 1633 | 1634 | id:str The artist ID 1635 | 1636 | Returns a dict like the following: 1637 | 1638 | {u'artist': {u'album': [{u'artist': u'Tune-Yards', 1639 | u'artistId': 1, 1640 | u'coverArt': u'al-7', 1641 | u'created': u'2012-01-30T12:35:33', 1642 | u'duration': 3229, 1643 | u'id': 7, 1644 | u'name': u'Bird-Brains', 1645 | u'songCount': 13}, 1646 | {u'artist': u'Tune-Yards', 1647 | u'artistId': 1, 1648 | u'coverArt': u'al-8', 1649 | u'created': u'2011-03-22T15:08:00', 1650 | u'duration': 2531, 1651 | u'id': 8, 1652 | u'name': u'W H O K I L L', 1653 | u'songCount': 10}], 1654 | u'albumCount': 2, 1655 | u'coverArt': u'ar-1', 1656 | u'id': 1, 1657 | u'name': u'Tune-Yards'}, 1658 | u'status': u'ok', 1659 | u'version': u'1.8.0', 1660 | u'xmlns': u'http://subsonic.org/restapi'} 1661 | """ 1662 | methodName = 'getArtist' 1663 | viewName = '%s.view' % methodName 1664 | 1665 | q = self._getQueryDict({'id': id}) 1666 | 1667 | req = self._getRequest(viewName, q) 1668 | res = self._doInfoReq(req) 1669 | self._checkStatus(res) 1670 | return res 1671 | 1672 | def getAlbum(self, id): 1673 | """ 1674 | since 1.8.0 1675 | 1676 | Returns the info and songs for an album. This method uses 1677 | the ID3 tags for organization 1678 | 1679 | id:str The album ID 1680 | 1681 | Returns a dict like the following: 1682 | 1683 | {u'album': {u'artist': u'Massive Attack', 1684 | u'artistId': 0, 1685 | u'coverArt': u'al-0', 1686 | u'created': u'2009-08-28T10:00:44', 1687 | u'duration': 3762, 1688 | u'id': 0, 1689 | u'name': u'100th Window', 1690 | u'song': [{u'album': u'100th Window', 1691 | u'albumId': 0, 1692 | u'artist': u'Massive Attack', 1693 | u'artistId': 0, 1694 | u'bitRate': 192, 1695 | u'contentType': u'audio/mpeg', 1696 | u'coverArt': 2, 1697 | u'created': u'2009-08-28T10:00:57', 1698 | u'duration': 341, 1699 | u'genre': u'Rock', 1700 | u'id': 14, 1701 | u'isDir': False, 1702 | u'isVideo': False, 1703 | u'parent': 2, 1704 | u'path': u'Massive Attack/100th Window/01 - Future Proof.mp3', 1705 | u'size': 8184445, 1706 | u'suffix': u'mp3', 1707 | u'title': u'Future Proof', 1708 | u'track': 1, 1709 | u'type': u'music', 1710 | u'year': 2003}], 1711 | u'songCount': 9}, 1712 | u'status': u'ok', 1713 | u'version': u'1.8.0', 1714 | u'xmlns': u'http://subsonic.org/restapi'} 1715 | """ 1716 | methodName = 'getAlbum' 1717 | viewName = '%s.view' % methodName 1718 | 1719 | q = self._getQueryDict({'id': id}) 1720 | 1721 | req = self._getRequest(viewName, q) 1722 | res = self._doInfoReq(req) 1723 | self._checkStatus(res) 1724 | return res 1725 | 1726 | def getSong(self, id): 1727 | """ 1728 | since 1.8.0 1729 | 1730 | Returns the info for a song. This method uses the ID3 1731 | tags for organization 1732 | 1733 | id:str The song ID 1734 | 1735 | Returns a dict like the following: 1736 | {u'song': {u'album': u'W H O K I L L', 1737 | u'albumId': 8, 1738 | u'artist': u'Tune-Yards', 1739 | u'artistId': 1, 1740 | u'bitRate': 320, 1741 | u'contentType': u'audio/mpeg', 1742 | u'coverArt': 106, 1743 | u'created': u'2011-03-22T15:08:00', 1744 | u'discNumber': 1, 1745 | u'duration': 192, 1746 | u'genre': u'Indie Rock', 1747 | u'id': 120, 1748 | u'isDir': False, 1749 | u'isVideo': False, 1750 | u'parent': 106, 1751 | u'path': u'Tune Yards/Who Kill/10 Killa.mp3', 1752 | u'size': 7692656, 1753 | u'suffix': u'mp3', 1754 | u'title': u'Killa', 1755 | u'track': 10, 1756 | u'type': u'music', 1757 | u'year': 2011}, 1758 | u'status': u'ok', 1759 | u'version': u'1.8.0', 1760 | u'xmlns': u'http://subsonic.org/restapi'} 1761 | """ 1762 | methodName = 'getSong' 1763 | viewName = '%s.view' % methodName 1764 | 1765 | q = self._getQueryDict({'id': id}) 1766 | 1767 | req = self._getRequest(viewName, q) 1768 | res = self._doInfoReq(req) 1769 | self._checkStatus(res) 1770 | return res 1771 | 1772 | def getVideos(self): 1773 | """ 1774 | since 1.8.0 1775 | 1776 | Returns all video files 1777 | 1778 | Returns a dict like the following: 1779 | {u'status': u'ok', 1780 | u'version': u'1.8.0', 1781 | u'videos': {u'video': {u'bitRate': 384, 1782 | u'contentType': u'video/x-matroska', 1783 | u'created': u'2012-08-26T13:36:44', 1784 | u'duration': 1301, 1785 | u'id': 130, 1786 | u'isDir': False, 1787 | u'isVideo': True, 1788 | u'path': u'South Park - 16x07 - Cartman Finds Love.mkv', 1789 | u'size': 287309613, 1790 | u'suffix': u'mkv', 1791 | u'title': u'South Park - 16x07 - Cartman Finds Love', 1792 | u'transcodedContentType': u'video/x-flv', 1793 | u'transcodedSuffix': u'flv'}}, 1794 | u'xmlns': u'http://subsonic.org/restapi'} 1795 | """ 1796 | methodName = 'getVideos' 1797 | viewName = '%s.view' % methodName 1798 | 1799 | req = self._getRequest(viewName) 1800 | res = self._doInfoReq(req) 1801 | self._checkStatus(res) 1802 | return res 1803 | 1804 | def getStarred(self, musicFolderId=None): 1805 | """ 1806 | since 1.8.0 1807 | 1808 | musicFolderId:int Only return results from the music folder 1809 | with the given ID. See getMusicFolders 1810 | 1811 | Returns starred songs, albums and artists 1812 | 1813 | Returns a dict like the following: 1814 | {u'starred': {u'album': {u'album': u'Bird-Brains', 1815 | u'artist': u'Tune-Yards', 1816 | u'coverArt': 105, 1817 | u'created': u'2012-01-30T13:16:58', 1818 | u'id': 105, 1819 | u'isDir': True, 1820 | u'parent': 104, 1821 | u'starred': u'2012-08-26T13:18:34', 1822 | u'title': u'Bird-Brains'}, 1823 | u'song': [{u'album': u'Mezzanine', 1824 | u'albumId': 4, 1825 | u'artist': u'Massive Attack', 1826 | u'artistId': 0, 1827 | u'bitRate': 256, 1828 | u'contentType': u'audio/mpeg', 1829 | u'coverArt': 6, 1830 | u'created': u'2009-06-15T07:48:28', 1831 | u'duration': 298, 1832 | u'genre': u'Dub', 1833 | u'id': 72, 1834 | u'isDir': False, 1835 | u'isVideo': False, 1836 | u'parent': 6, 1837 | u'path': u'Massive Attack/Mezzanine/Massive Attack_02_mezzanine.mp3', 1838 | u'size': 9564160, 1839 | u'starred': u'2012-08-26T13:19:26', 1840 | u'suffix': u'mp3', 1841 | u'title': u'Risingson', 1842 | u'track': 2, 1843 | u'type': u'music'}, 1844 | {u'album': u'Mezzanine', 1845 | u'albumId': 4, 1846 | u'artist': u'Massive Attack', 1847 | u'artistId': 0, 1848 | u'bitRate': 256, 1849 | u'contentType': u'audio/mpeg', 1850 | u'coverArt': 6, 1851 | u'created': u'2009-06-15T07:48:25', 1852 | u'duration': 380, 1853 | u'genre': u'Dub', 1854 | u'id': 71, 1855 | u'isDir': False, 1856 | u'isVideo': False, 1857 | u'parent': 6, 1858 | u'path': u'Massive Attack/Mezzanine/Massive Attack_01_mezzanine.mp3', 1859 | u'size': 12179456, 1860 | u'starred': u'2012-08-26T13:19:03', 1861 | u'suffix': u'mp3', 1862 | u'title': u'Angel', 1863 | u'track': 1, 1864 | u'type': u'music'}]}, 1865 | u'status': u'ok', 1866 | u'version': u'1.8.0', 1867 | u'xmlns': u'http://subsonic.org/restapi'} 1868 | """ 1869 | methodName = 'getStarred' 1870 | viewName = '%s.view' % methodName 1871 | 1872 | q = {} 1873 | if musicFolderId: 1874 | q['musicFolderId'] = musicFolderId 1875 | 1876 | req = self._getRequest(viewName, q) 1877 | res = self._doInfoReq(req) 1878 | self._checkStatus(res) 1879 | return res 1880 | 1881 | def getStarred2(self, musicFolderId=None): 1882 | """ 1883 | since 1.8.0 1884 | 1885 | musicFolderId:int Only return results from the music folder 1886 | with the given ID. See getMusicFolders 1887 | 1888 | Returns starred songs, albums and artists like getStarred(), 1889 | but this uses ID3 tags for organization 1890 | 1891 | Returns a dict like the following: 1892 | 1893 | **See the output from getStarred()** 1894 | """ 1895 | methodName = 'getStarred2' 1896 | viewName = '%s.view' % methodName 1897 | 1898 | q = {} 1899 | if musicFolderId: 1900 | q['musicFolderId'] = musicFolderId 1901 | 1902 | req = self._getRequest(viewName, q) 1903 | res = self._doInfoReq(req) 1904 | self._checkStatus(res) 1905 | return res 1906 | 1907 | def updatePlaylist(self, lid, name=None, comment=None, songIdsToAdd=[], 1908 | songIndexesToRemove=[]): 1909 | """ 1910 | since 1.8.0 1911 | 1912 | Updates a playlist. Only the owner of a playlist is allowed to 1913 | update it. 1914 | 1915 | lid:str The playlist id 1916 | name:str The human readable name of the playlist 1917 | comment:str The playlist comment 1918 | songIdsToAdd:list A list of song IDs to add to the playlist 1919 | songIndexesToRemove:list Remove the songs at the 1920 | 0 BASED INDEXED POSITIONS in the 1921 | playlist, NOT the song ids. Note that 1922 | this is always a list. 1923 | 1924 | Returns a normal status response dict 1925 | """ 1926 | methodName = 'updatePlaylist' 1927 | viewName = '%s.view' % methodName 1928 | 1929 | q = self._getQueryDict({'playlistId': lid, 'name': name, 1930 | 'comment': comment}) 1931 | if not isinstance(songIdsToAdd, list) or isinstance(songIdsToAdd, 1932 | tuple): 1933 | songIdsToAdd = [songIdsToAdd] 1934 | if not isinstance(songIndexesToRemove, list) or isinstance( 1935 | songIndexesToRemove, tuple): 1936 | songIndexesToRemove = [songIndexesToRemove] 1937 | listMap = {'songIdToAdd': songIdsToAdd, 1938 | 'songIndexToRemove': songIndexesToRemove} 1939 | req = self._getRequestWithLists(viewName, listMap, q) 1940 | res = self._doInfoReq(req) 1941 | self._checkStatus(res) 1942 | return res 1943 | 1944 | def getAvatar(self, username): 1945 | """ 1946 | since 1.8.0 1947 | 1948 | Returns the avatar for a user or None if the avatar does not exist 1949 | 1950 | username:str The user to retrieve the avatar for 1951 | 1952 | Returns the file-like object for reading or raises an exception 1953 | on error 1954 | """ 1955 | methodName = 'getAvatar' 1956 | viewName = '%s.view' % methodName 1957 | 1958 | q = {'username': username} 1959 | 1960 | req = self._getRequest(viewName, q) 1961 | try: 1962 | res = self._doBinReq(req) 1963 | except urllib.error.HTTPError: 1964 | # Avatar is not set/does not exist, return None 1965 | return None 1966 | if isinstance(res, dict): 1967 | self._checkStatus(res) 1968 | return res 1969 | 1970 | def star(self, sids=[], albumIds=[], artistIds=[]): 1971 | """ 1972 | since 1.8.0 1973 | 1974 | Attaches a star to songs, albums or artists 1975 | 1976 | sids:list A list of song IDs to star 1977 | albumIds:list A list of album IDs to star. Use this rather than 1978 | "sids" if the client access the media collection 1979 | according to ID3 tags rather than file 1980 | structure 1981 | artistIds:list The ID of an artist to star. Use this rather 1982 | than sids if the client access the media 1983 | collection according to ID3 tags rather 1984 | than file structure 1985 | 1986 | Returns a normal status response dict 1987 | """ 1988 | methodName = 'star' 1989 | viewName = '%s.view' % methodName 1990 | 1991 | if not isinstance(sids, list) or isinstance(sids, tuple): 1992 | sids = [sids] 1993 | if not isinstance(albumIds, list) or isinstance(albumIds, tuple): 1994 | albumIds = [albumIds] 1995 | if not isinstance(artistIds, list) or isinstance(artistIds, tuple): 1996 | artistIds = [artistIds] 1997 | listMap = {'id': sids, 1998 | 'albumId': albumIds, 1999 | 'artistId': artistIds} 2000 | req = self._getRequestWithLists(viewName, listMap) 2001 | res = self._doInfoReq(req) 2002 | self._checkStatus(res) 2003 | return res 2004 | 2005 | def unstar(self, sids=[], albumIds=[], artistIds=[]): 2006 | """ 2007 | since 1.8.0 2008 | 2009 | Removes a star to songs, albums or artists. Basically, the 2010 | same as star in reverse 2011 | 2012 | sids:list A list of song IDs to star 2013 | albumIds:list A list of album IDs to star. Use this rather than 2014 | "sids" if the client access the media collection 2015 | according to ID3 tags rather than file 2016 | structure 2017 | artistIds:list The ID of an artist to star. Use this rather 2018 | than sids if the client access the media 2019 | collection according to ID3 tags rather 2020 | than file structure 2021 | 2022 | Returns a normal status response dict 2023 | """ 2024 | methodName = 'unstar' 2025 | viewName = '%s.view' % methodName 2026 | 2027 | if not isinstance(sids, list) or isinstance(sids, tuple): 2028 | sids = [sids] 2029 | if not isinstance(albumIds, list) or isinstance(albumIds, tuple): 2030 | albumIds = [albumIds] 2031 | if not isinstance(artistIds, list) or isinstance(artistIds, tuple): 2032 | artistIds = [artistIds] 2033 | listMap = {'id': sids, 2034 | 'albumId': albumIds, 2035 | 'artistId': artistIds} 2036 | req = self._getRequestWithLists(viewName, listMap) 2037 | res = self._doInfoReq(req) 2038 | self._checkStatus(res) 2039 | return res 2040 | 2041 | def getGenres(self): 2042 | """ 2043 | since 1.9.0 2044 | 2045 | Returns all genres 2046 | """ 2047 | methodName = 'getGenres' 2048 | viewName = '%s.view' % methodName 2049 | 2050 | req = self._getRequest(viewName) 2051 | res = self._doInfoReq(req) 2052 | self._checkStatus(res) 2053 | return res 2054 | 2055 | def getSongsByGenre(self, genre, count=10, offset=0, musicFolderId=None): 2056 | """ 2057 | since 1.9.0 2058 | 2059 | Returns songs in a given genre 2060 | 2061 | genre:str The genre, as returned by getGenres() 2062 | count:int The maximum number of songs to return. Max is 500 2063 | default: 10 2064 | offset:int The offset if you are paging. default: 0 2065 | musicFolderId:int Only return results from the music folder 2066 | with the given ID. See getMusicFolders 2067 | """ 2068 | methodName = 'getSongsByGenre' 2069 | viewName = '%s.view' % methodName 2070 | 2071 | q = self._getQueryDict({'genre': genre, 2072 | 'count': count, 2073 | 'offset': offset, 2074 | 'musicFolderId': musicFolderId, 2075 | }) 2076 | 2077 | req = self._getRequest(viewName, q) 2078 | res = self._doInfoReq(req) 2079 | self._checkStatus(res) 2080 | return res 2081 | 2082 | def hls (self, mid, bitrate=None): 2083 | """ 2084 | since 1.8.0 2085 | 2086 | Creates an HTTP live streaming playlist for streaming video or 2087 | audio HLS is a streaming protocol implemented by Apple and 2088 | works by breaking the overall stream into a sequence of small 2089 | HTTP-based file downloads. It's supported by iOS and newer 2090 | versions of Android. This method also supports adaptive 2091 | bitrate streaming, see the bitRate parameter. 2092 | 2093 | mid:str The ID of the media to stream 2094 | bitrate:str If specified, the server will attempt to limit the 2095 | bitrate to this value, in kilobits per second. If 2096 | this parameter is specified more than once, the 2097 | server will create a variant playlist, suitable 2098 | for adaptive bitrate streaming. The playlist will 2099 | support streaming at all the specified bitrates. 2100 | The server will automatically choose video dimensions 2101 | that are suitable for the given bitrates. 2102 | (since: 1.9.0) you may explicitly request a certain 2103 | width (480) and height (360) like so: 2104 | bitRate=1000@480x360 2105 | 2106 | Returns the raw m3u8 file as a string 2107 | """ 2108 | methodName = 'hls' 2109 | viewName = '%s.view' % methodName 2110 | 2111 | q = self._getQueryDict({'id': mid, 'bitrate': bitrate}) 2112 | req = self._getRequest(viewName, q) 2113 | try: 2114 | res = self._doBinReq(req) 2115 | except urllib.error.HTTPError: 2116 | # Avatar is not set/does not exist, return None 2117 | return None 2118 | if isinstance(res, dict): 2119 | self._checkStatus(res) 2120 | return res.read() 2121 | 2122 | def refreshPodcasts(self): 2123 | """ 2124 | since: 1.9.0 2125 | 2126 | Tells the server to check for new Podcast episodes. Note: The user 2127 | must be authorized for Podcast administration 2128 | """ 2129 | methodName = 'refreshPodcasts' 2130 | viewName = '%s.view' % methodName 2131 | 2132 | req = self._getRequest(viewName) 2133 | res = self._doInfoReq(req) 2134 | self._checkStatus(res) 2135 | return res 2136 | 2137 | def createPodcastChannel(self, url): 2138 | """ 2139 | since: 1.9.0 2140 | 2141 | Adds a new Podcast channel. Note: The user must be authorized 2142 | for Podcast administration 2143 | 2144 | url:str The URL of the Podcast to add 2145 | """ 2146 | methodName = 'createPodcastChannel' 2147 | viewName = '%s.view' % methodName 2148 | 2149 | q = {'url': url} 2150 | 2151 | req = self._getRequest(viewName, q) 2152 | res = self._doInfoReq(req) 2153 | self._checkStatus(res) 2154 | return res 2155 | 2156 | def deletePodcastChannel(self, pid): 2157 | """ 2158 | since: 1.9.0 2159 | 2160 | Deletes a Podcast channel. Note: The user must be authorized 2161 | for Podcast administration 2162 | 2163 | pid:str The ID of the Podcast channel to delete 2164 | """ 2165 | methodName = 'deletePodcastChannel' 2166 | viewName = '%s.view' % methodName 2167 | 2168 | q = {'id': pid} 2169 | 2170 | req = self._getRequest(viewName, q) 2171 | res = self._doInfoReq(req) 2172 | self._checkStatus(res) 2173 | return res 2174 | 2175 | def deletePodcastEpisode(self, pid): 2176 | """ 2177 | since: 1.9.0 2178 | 2179 | Deletes a Podcast episode. Note: The user must be authorized 2180 | for Podcast administration 2181 | 2182 | pid:str The ID of the Podcast episode to delete 2183 | """ 2184 | methodName = 'deletePodcastEpisode' 2185 | viewName = '%s.view' % methodName 2186 | 2187 | q = {'id': pid} 2188 | 2189 | req = self._getRequest(viewName, q) 2190 | res = self._doInfoReq(req) 2191 | self._checkStatus(res) 2192 | return res 2193 | 2194 | def downloadPodcastEpisode(self, pid): 2195 | """ 2196 | since: 1.9.0 2197 | 2198 | Tells the server to start downloading a given Podcast episode. 2199 | Note: The user must be authorized for Podcast administration 2200 | 2201 | pid:str The ID of the Podcast episode to download 2202 | """ 2203 | methodName = 'downloadPodcastEpisode' 2204 | viewName = '%s.view' % methodName 2205 | 2206 | q = {'id': pid} 2207 | 2208 | req = self._getRequest(viewName, q) 2209 | res = self._doInfoReq(req) 2210 | self._checkStatus(res) 2211 | return res 2212 | 2213 | def getInternetRadioStations(self): 2214 | """ 2215 | since: 1.9.0 2216 | 2217 | Returns all internet radio stations 2218 | """ 2219 | methodName = 'getInternetRadioStations' 2220 | viewName = '%s.view' % methodName 2221 | 2222 | req = self._getRequest(viewName) 2223 | res = self._doInfoReq(req) 2224 | self._checkStatus(res) 2225 | return res 2226 | 2227 | def createInternetRadioStation(self, streamUrl, name, homepageUrl=None): 2228 | """ 2229 | since 1.16.0 2230 | 2231 | Create an internet radio station 2232 | 2233 | streamUrl:str The stream URL for the station 2234 | name:str The user-defined name for the station 2235 | homepageUrl:str The homepage URL for the station 2236 | """ 2237 | methodName = 'createInternetRadioStation' 2238 | viewName = '{}.view'.format(methodName) 2239 | 2240 | q = self._getQueryDict({ 2241 | 'streamUrl': streamUrl, 'name': name, 'homepageUrl': homepageUrl}) 2242 | 2243 | req = self._getRequest(viewName, q) 2244 | res = self._doInfoReq(req) 2245 | self._checkStatus(res) 2246 | return res 2247 | 2248 | def updateInternetRadioStation(self, iid, streamUrl, name, 2249 | homepageUrl=None): 2250 | """ 2251 | since 1.16.0 2252 | 2253 | Create an internet radio station 2254 | 2255 | iid:str The ID for the station 2256 | streamUrl:str The stream URL for the station 2257 | name:str The user-defined name for the station 2258 | homepageUrl:str The homepage URL for the station 2259 | """ 2260 | methodName = 'updateInternetRadioStation' 2261 | viewName = '{}.view'.format(methodName) 2262 | 2263 | q = self._getQueryDict({ 2264 | 'id': iid, 'streamUrl': streamUrl, 'name': name, 2265 | 'homepageUrl': homepageUrl, 2266 | }) 2267 | 2268 | req = self._getRequest(viewName, q) 2269 | res = self._doInfoReq(req) 2270 | self._checkStatus(res) 2271 | return res 2272 | 2273 | def deleteInternetRadioStation(self, iid): 2274 | """ 2275 | since 1.16.0 2276 | 2277 | Create an internet radio station 2278 | 2279 | iid:str The ID for the station 2280 | """ 2281 | methodName = 'deleteInternetRadioStation' 2282 | viewName = '{}.view'.format(methodName) 2283 | 2284 | q = {'id': iid} 2285 | 2286 | req = self._getRequest(viewName, q) 2287 | res = self._doInfoReq(req) 2288 | self._checkStatus(res) 2289 | return res 2290 | 2291 | def getBookmarks(self): 2292 | """ 2293 | since: 1.9.0 2294 | 2295 | Returns all bookmarks for this user. A bookmark is a position 2296 | within a media file 2297 | """ 2298 | methodName = 'getBookmarks' 2299 | viewName = '%s.view' % methodName 2300 | 2301 | req = self._getRequest(viewName) 2302 | res = self._doInfoReq(req) 2303 | self._checkStatus(res) 2304 | return res 2305 | 2306 | def createBookmark(self, mid, position, comment=None): 2307 | """ 2308 | since: 1.9.0 2309 | 2310 | Creates or updates a bookmark (position within a media file). 2311 | Bookmarks are personal and not visible to other users 2312 | 2313 | mid:str The ID of the media file to bookmark. If a bookmark 2314 | already exists for this file, it will be overwritten 2315 | position:int The position (in milliseconds) within the media file 2316 | comment:str A user-defined comment 2317 | """ 2318 | methodName = 'createBookmark' 2319 | viewName = '%s.view' % methodName 2320 | 2321 | q = self._getQueryDict({'id': mid, 'position': position, 2322 | 'comment': comment}) 2323 | 2324 | req = self._getRequest(viewName, q) 2325 | res = self._doInfoReq(req) 2326 | self._checkStatus(res) 2327 | return res 2328 | 2329 | def deleteBookmark(self, mid): 2330 | """ 2331 | since: 1.9.0 2332 | 2333 | Deletes the bookmark for a given file 2334 | 2335 | mid:str The ID of the media file to delete the bookmark from. 2336 | Other users' bookmarks are not affected 2337 | """ 2338 | methodName = 'deleteBookmark' 2339 | viewName = '%s.view' % methodName 2340 | 2341 | q = {'id': mid} 2342 | 2343 | req = self._getRequest(viewName, q) 2344 | res = self._doInfoReq(req) 2345 | self._checkStatus(res) 2346 | return res 2347 | 2348 | def getArtistInfo(self, aid, count=20, includeNotPresent=False): 2349 | """ 2350 | since: 1.11.0 2351 | 2352 | Returns artist info with biography, image URLS and similar artists 2353 | using data from last.fm 2354 | 2355 | aid:str The ID of the artist, album or song 2356 | count:int The max number of similar artists to return 2357 | includeNotPresent:bool Whether to return artists that are not 2358 | present in the media library 2359 | """ 2360 | methodName = 'getArtistInfo' 2361 | viewName = '%s.view' % methodName 2362 | 2363 | q = {'id': aid, 'count': count, 2364 | 'includeNotPresent': includeNotPresent} 2365 | 2366 | req = self._getRequest(viewName, q) 2367 | res = self._doInfoReq(req) 2368 | self._checkStatus(res) 2369 | return res 2370 | 2371 | def getArtistInfo2(self, aid, count=20, includeNotPresent=False): 2372 | """ 2373 | since: 1.11.0 2374 | 2375 | Similar to getArtistInfo(), but organizes music according to ID3 tags 2376 | 2377 | aid:str The ID of the artist, album or song 2378 | count:int The max number of similar artists to return 2379 | includeNotPresent:bool Whether to return artists that are not 2380 | present in the media library 2381 | """ 2382 | methodName = 'getArtistInfo2' 2383 | viewName = '%s.view' % methodName 2384 | 2385 | q = {'id': aid, 'count': count, 2386 | 'includeNotPresent': includeNotPresent} 2387 | 2388 | req = self._getRequest(viewName, q) 2389 | res = self._doInfoReq(req) 2390 | self._checkStatus(res) 2391 | return res 2392 | 2393 | def getSimilarSongs(self, iid, count=50): 2394 | """ 2395 | since 1.11.0 2396 | 2397 | Returns a random collection of songs from the given artist and 2398 | similar artists, using data from last.fm. Typically used for 2399 | artist radio features. 2400 | 2401 | iid:str The artist, album, or song ID 2402 | count:int Max number of songs to return 2403 | """ 2404 | methodName = 'getSimilarSongs' 2405 | viewName = '%s.view' % methodName 2406 | 2407 | q = {'id': iid, 'count': count} 2408 | 2409 | req = self._getRequest(viewName, q) 2410 | res = self._doInfoReq(req) 2411 | self._checkStatus(res) 2412 | return res 2413 | 2414 | def getSimilarSongs2(self, iid, count=50): 2415 | """ 2416 | since 1.11.0 2417 | 2418 | Similar to getSimilarSongs(), but organizes music according to 2419 | ID3 tags 2420 | 2421 | iid:str The artist, album, or song ID 2422 | count:int Max number of songs to return 2423 | """ 2424 | methodName = 'getSimilarSongs2' 2425 | viewName = '%s.view' % methodName 2426 | 2427 | q = {'id': iid, 'count': count} 2428 | 2429 | req = self._getRequest(viewName, q) 2430 | res = self._doInfoReq(req) 2431 | self._checkStatus(res) 2432 | return res 2433 | 2434 | def savePlayQueue(self, qids, current=None, position=None): 2435 | """ 2436 | since 1.12.0 2437 | 2438 | qid:list[int] The list of song ids in the play queue 2439 | current:int The id of the current playing song 2440 | position:int The position, in milliseconds, within the current 2441 | playing song 2442 | 2443 | Saves the state of the play queue for this user. This includes 2444 | the tracks in the play queue, the currently playing track, and 2445 | the position within this track. Typically used to allow a user to 2446 | move between different clients/apps while retaining the same play 2447 | queue (for instance when listening to an audio book). 2448 | """ 2449 | methodName = 'savePlayQueue' 2450 | viewName = '%s.view' % methodName 2451 | 2452 | if not isinstance(qids, (tuple, list)): 2453 | qids = [qids] 2454 | 2455 | q = self._getQueryDict({'current': current, 'position': position}) 2456 | 2457 | req = self._getRequestWithLists(viewName, {'id': qids}, q) 2458 | res = self._doInfoReq(req) 2459 | self._checkStatus(res) 2460 | return res 2461 | 2462 | def getPlayQueue(self): 2463 | """ 2464 | since 1.12.0 2465 | 2466 | Returns the state of the play queue for this user (as set by 2467 | savePlayQueue). This includes the tracks in the play queue, 2468 | the currently playing track, and the position within this track. 2469 | Typically used to allow a user to move between different 2470 | clients/apps while retaining the same play queue (for instance 2471 | when listening to an audio book). 2472 | """ 2473 | methodName = 'getPlayQueue' 2474 | viewName = '%s.view' % methodName 2475 | 2476 | req = self._getRequest(viewName) 2477 | res = self._doInfoReq(req) 2478 | self._checkStatus(res) 2479 | return res 2480 | 2481 | def getTopSongs(self, artist, count=50): 2482 | """ 2483 | since 1.13.0 2484 | 2485 | Returns the top songs for a given artist 2486 | 2487 | artist:str The artist to get songs for 2488 | count:int The number of songs to return 2489 | """ 2490 | methodName = 'getTopSongs' 2491 | viewName = '%s.view' % methodName 2492 | 2493 | q = {'artist': artist, 'count': count} 2494 | 2495 | req = self._getRequest(viewName, q) 2496 | res = self._doInfoReq(req) 2497 | self._checkStatus(res) 2498 | return res 2499 | 2500 | def getNewestPodcasts(self, count=20): 2501 | """ 2502 | since 1.13.0 2503 | 2504 | Returns the most recently published Podcast episodes 2505 | 2506 | count:int The number of episodes to return 2507 | """ 2508 | methodName = 'getNewestPodcasts' 2509 | viewName = '%s.view' % methodName 2510 | 2511 | q = {'count': count} 2512 | 2513 | req = self._getRequest(viewName, q) 2514 | res = self._doInfoReq(req) 2515 | self._checkStatus(res) 2516 | return res 2517 | 2518 | def scanMediaFolders(self): 2519 | """ 2520 | This is not an officially supported method of the API 2521 | 2522 | Same as selecting 'Settings' > 'Scan media folders now' with 2523 | Subsonic web GUI 2524 | 2525 | Returns True if refresh successful, False otherwise 2526 | """ 2527 | viewName = 'scanNow' 2528 | return self._unsupportedAPIFunction(methodName) 2529 | 2530 | def cleanupDatabase(self): 2531 | """ 2532 | This is not an officially supported method of the API 2533 | 2534 | Same as selecting 'Settings' > 'Clean-up Database' with Subsonic 2535 | web GUI 2536 | 2537 | Returns True if cleanup initiated successfully, False otherwise 2538 | 2539 | Subsonic stores information about all media files ever encountered. 2540 | By cleaning up the database, information about files that are 2541 | no longer in your media collection is permanently removed. 2542 | """ 2543 | viewName = 'expunge' 2544 | return self._unsupportedAPIFunction(methodName) 2545 | 2546 | def getVideoInfo(self, vid): 2547 | """ 2548 | since 1.14.0 2549 | 2550 | Returns details for a video, including information about available 2551 | audio tracks, subtitles (captions) and conversions. 2552 | 2553 | vid:int The video ID 2554 | """ 2555 | methodName = 'getVideoInfo' 2556 | viewName = '%s.view' % methodName 2557 | 2558 | q = {'id': int(vid)} 2559 | req = self._getRequest(viewName, q) 2560 | res = self._doInfoReq(req) 2561 | self._checkStatus(res) 2562 | return res 2563 | 2564 | def getAlbumInfo(self, aid): 2565 | """ 2566 | since 1.14.0 2567 | 2568 | Returns the album notes, image URLs, etc., using data from last.fm 2569 | 2570 | aid:int The album ID 2571 | """ 2572 | methodName = 'getAlbumInfo' 2573 | viewName = '%s.view' % methodName 2574 | 2575 | q = {'id': aid} 2576 | req = self._getRequest(viewName, q) 2577 | res = self._doInfoReq(req) 2578 | self._checkStatus(res) 2579 | return res 2580 | 2581 | def getAlbumInfo2(self, aid): 2582 | """ 2583 | since 1.14.0 2584 | 2585 | Same as getAlbumInfo, but uses ID3 tags 2586 | 2587 | aid:int The album ID 2588 | """ 2589 | methodName = 'getAlbumInfo2' 2590 | viewName = '%s.view' % methodName 2591 | 2592 | q = {'id': aid} 2593 | req = self._getRequest(viewName, q) 2594 | res = self._doInfoReq(req) 2595 | self._checkStatus(res) 2596 | return res 2597 | 2598 | def getCaptions(self, vid, fmt=None): 2599 | """ 2600 | since 1.14.0 2601 | 2602 | Returns captions (subtitles) for a video. Use getVideoInfo for a list 2603 | of captions. 2604 | 2605 | vid:int The ID of the video 2606 | fmt:str Preferred captions format ("srt" or "vtt") 2607 | """ 2608 | methodName = 'getCaptions' 2609 | viewName = '%s.view' % methodName 2610 | 2611 | q = self._getQueryDict({'id': int(vid), 'format': fmt}) 2612 | req = self._getRequest(viewName, q) 2613 | res = self._doInfoReq(req) 2614 | self._checkStatus(res) 2615 | return res 2616 | 2617 | def _unsupportedAPIFunction(self, methodName): 2618 | """ 2619 | base function to call unsupported API methods 2620 | 2621 | Returns True if refresh successful, False otherwise 2622 | :rtype : boolean 2623 | """ 2624 | baseMethod = 'musicFolderSettings' 2625 | viewName = '%s.view' % baseMethod 2626 | 2627 | url = '%s:%d/%s/%s?%s' % (self._baseUrl, self._port, 2628 | self._separateServerPath(), viewName, methodName) 2629 | req = urllib.request.Request(url) 2630 | res = self._opener.open(req) 2631 | res_msg = res.msg.lower() 2632 | return res_msg == 'ok' 2633 | 2634 | # 2635 | # Private internal methods 2636 | # 2637 | def _getOpener(self, username, passwd): 2638 | return urllib.request.build_opener() 2639 | 2640 | def _getQueryDict(self, d): 2641 | """ 2642 | Given a dictionary, it cleans out all the values set to None 2643 | """ 2644 | for k, v in list(d.items()): 2645 | if v is None: 2646 | del d[k] 2647 | return d 2648 | 2649 | def _getBaseQdict(self): 2650 | qdict = { 2651 | 'f': 'json', 2652 | 'v': self._apiVersion, 2653 | 'c': self._appName, 2654 | 'u': self._username, 2655 | } 2656 | 2657 | if self._legacyAuth: 2658 | qdict['p'] = 'enc:%s' % self._hexEnc(self._rawPass) 2659 | else: 2660 | if self._rawPass: 2661 | salt = self._getSalt() 2662 | token = md5((self._rawPass + salt).encode('utf-8')).hexdigest() 2663 | else: 2664 | salt = self._salt 2665 | token = self._token 2666 | qdict.update({ 2667 | 's': salt, 2668 | 't': token, 2669 | }) 2670 | 2671 | return qdict 2672 | 2673 | def _getRequest(self, viewName, query={}): 2674 | qdict = self._getBaseQdict() 2675 | qdict.update(query) 2676 | url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath, 2677 | viewName) 2678 | req = urllib.request.Request(url, urlencode(qdict).encode('utf-8')) 2679 | 2680 | if self._useGET: 2681 | url += '?%s' % urlencode(qdict) 2682 | req = urllib.request.Request(url) 2683 | 2684 | if self._userAgent: 2685 | req.add_header('User-Agent', self._userAgent) 2686 | 2687 | return req 2688 | 2689 | def _getRequestWithList(self, viewName, listName, alist, query={}): 2690 | """ 2691 | Like _getRequest, but allows appending a number of items with the 2692 | same key (listName). This bypasses the limitation of urlencode() 2693 | """ 2694 | qdict = self._getBaseQdict() 2695 | qdict.update(query) 2696 | url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath, 2697 | viewName) 2698 | data = StringIO() 2699 | data.write(urlencode(qdict)) 2700 | for i in alist: 2701 | data.write('&%s' % urlencode({listName: i})) 2702 | req = urllib.request.Request(url, data.getvalue().encode('utf-8')) 2703 | 2704 | if self._useGET: 2705 | url += '?%s' % data.getvalue() 2706 | req = urllib2.Request(url) 2707 | 2708 | if self._userAgent: 2709 | req.add_header('User-Agent', self._userAgent) 2710 | 2711 | return req 2712 | 2713 | def _getRequestWithLists(self, viewName, listMap, query={}): 2714 | """ 2715 | Like _getRequestWithList(), but you must pass a dictionary 2716 | that maps the listName to the list. This allows for multiple 2717 | list parameters to be used, like in updatePlaylist() 2718 | 2719 | viewName:str The name of the view 2720 | listMap:dict A mapping of listName to a list of entries 2721 | query:dict The normal query dict 2722 | """ 2723 | qdict = self._getBaseQdict() 2724 | qdict.update(query) 2725 | url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath, 2726 | viewName) 2727 | data = StringIO() 2728 | data.write(urlencode(qdict)) 2729 | for k, l in listMap.items(): 2730 | for i in l: 2731 | data.write('&%s' % urlencode({k: i})) 2732 | req = urllib.request.Request(url, data.getvalue().encode('utf-8')) 2733 | 2734 | if self._useGET: 2735 | url += '?%s' % data.getvalue() 2736 | req = urllib2.Request(url) 2737 | 2738 | if self._userAgent: 2739 | req.add_header('User-Agent', self._userAgent) 2740 | 2741 | return req 2742 | 2743 | def _doInfoReq(self, req): 2744 | # Returns a parsed dictionary version of the result 2745 | res = self._opener.open(req) 2746 | dres = json.loads(res.read().decode('utf-8')) 2747 | return dres['subsonic-response'] 2748 | 2749 | def _doBinReq(self, req): 2750 | res = self._opener.open(req) 2751 | info = res.info() 2752 | if hasattr(info, 'getheader'): 2753 | contType = info.getheader('Content-Type') 2754 | else: 2755 | contType = info.get('Content-Type') 2756 | 2757 | if contType: 2758 | if contType.startswith('text/html') or \ 2759 | contType.startswith('application/json'): 2760 | dres = json.loads(res.read()) 2761 | return dres['subsonic-response'] 2762 | return res 2763 | 2764 | def _checkStatus(self, result): 2765 | if result['status'] == 'ok': 2766 | return True 2767 | elif result['status'] == 'failed': 2768 | exc = getExcByCode(result['error']['code']) 2769 | raise exc(result['error']['message']) 2770 | 2771 | def _hexEnc(self, raw): 2772 | """ 2773 | Returns a "hex encoded" string per the Subsonic api docs 2774 | 2775 | raw:str The string to hex encode 2776 | """ 2777 | ret = '' 2778 | for c in raw: 2779 | ret += '%02X' % ord(c) 2780 | return ret 2781 | 2782 | def _ts2milli(self, ts): 2783 | """ 2784 | For whatever reason, Subsonic uses timestamps in milliseconds since 2785 | the unix epoch. I have no idea what need there is of this precision, 2786 | but this will just multiply the timestamp times 1000 and return the int 2787 | """ 2788 | if ts is None: 2789 | return None 2790 | return int(ts * 1000) 2791 | 2792 | def _separateServerPath(self): 2793 | """ 2794 | separate REST portion of URL from base server path. 2795 | """ 2796 | return urllib.parse.splithost(self._serverPath)[1].split('/')[0] 2797 | 2798 | def _fixLastModified(self, data): 2799 | """ 2800 | This will recursively walk through a data structure and look for 2801 | a dict key/value pair where the key is "lastModified" and change 2802 | the shitty java millisecond timestamp to a real unix timestamp 2803 | of SECONDS since the unix epoch. JAVA SUCKS! 2804 | """ 2805 | if isinstance(data, dict): 2806 | for k, v in list(data.items()): 2807 | if k == 'lastModified': 2808 | data[k] = int(v) / 1000.0 2809 | return 2810 | elif isinstance(v, (tuple, list, dict)): 2811 | return self._fixLastModified(v) 2812 | elif isinstance(data, (list, tuple)): 2813 | for item in data: 2814 | if isinstance(item, (list, tuple, dict)): 2815 | return self._fixLastModified(item) 2816 | 2817 | def _process_netrc(self, use_netrc): 2818 | """ 2819 | The use_netrc var is either a boolean, which means we should use 2820 | the user's default netrc, or a string specifying a path to a 2821 | netrc formatted file 2822 | 2823 | use_netrc:bool|str Either set to True to use the user's default 2824 | netrc file or a string specifying a specific 2825 | netrc file to use 2826 | """ 2827 | if not use_netrc: 2828 | raise CredentialError('useNetrc must be either a boolean "True" ' 2829 | 'or a string representing a path to a netrc file, ' 2830 | 'not {0}'.format(repr(use_netrc))) 2831 | if isinstance(use_netrc, bool) and use_netrc: 2832 | self._netrc = netrc() 2833 | else: 2834 | # This should be a string specifying a path to a netrc file 2835 | self._netrc = netrc(os.path.expanduser(use_netrc)) 2836 | auth = self._netrc.authenticators(self._hostname) 2837 | if not auth: 2838 | raise CredentialError('No machine entry found for {0} in ' 2839 | 'your netrc file'.format(self._hostname)) 2840 | 2841 | # If we get here, we have credentials 2842 | self._username = auth[0] 2843 | self._rawPass = auth[2] 2844 | 2845 | def _getSalt(self, length=12): 2846 | salt = md5(os.urandom(100)).hexdigest() 2847 | return salt[:length] 2848 | -------------------------------------------------------------------------------- /libsonic/errors.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of py-sonic. 3 | 4 | py-sonic is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | py-sonic is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with py-sonic. If not, see 16 | """ 17 | 18 | class SonicError(Exception): 19 | pass 20 | 21 | class ParameterError(SonicError): 22 | pass 23 | 24 | class VersionError(SonicError): 25 | pass 26 | 27 | class CredentialError(SonicError): 28 | pass 29 | 30 | class AuthError(SonicError): 31 | pass 32 | 33 | class LicenseError(SonicError): 34 | pass 35 | 36 | class DataNotFoundError(SonicError): 37 | pass 38 | 39 | class ArgumentError(SonicError): 40 | pass 41 | 42 | # This maps the error code numbers from the Subsonic server to their 43 | # appropriate Exceptions 44 | ERR_CODE_MAP = { 45 | 0: SonicError , 46 | 10: ParameterError , 47 | 20: VersionError , 48 | 30: VersionError , 49 | 40: CredentialError , 50 | 50: AuthError , 51 | 60: LicenseError , 52 | 70: DataNotFoundError , 53 | } 54 | 55 | def getExcByCode(code): 56 | code = int(code) 57 | if code in ERR_CODE_MAP: 58 | return ERR_CODE_MAP[code] 59 | return SonicError 60 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crustymonkey/py-sonic/e8f12f8c088668dc39a1b4ee98a068f3efa6b001/requirements.txt -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This file is part of py-sonic. 5 | 6 | py-sonic is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | py-sonic is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with py-sonic. If not, see 18 | """ 19 | 20 | from setuptools import setup 21 | from libsonic import __version__ as version 22 | import os 23 | 24 | req_file = os.path.join(os.path.dirname(__file__), 'requirements.txt') 25 | requirements = [line for line in open(req_file) if line] 26 | 27 | setup(name='py-sonic', 28 | version=version, 29 | author='Jay Deiman', 30 | author_email='admin@splitstreams.com', 31 | url='http://stuffivelearned.org', 32 | description='A python wrapper library for the Subsonic REST API. ' 33 | 'http://subsonic.org', 34 | long_description='This is a basic wrapper library for the Subsonic ' 35 | 'REST API. This will allow you to connect to your server and retrieve ' 36 | 'information and have it returned in basic Python types.', 37 | packages=['libsonic'], 38 | package_dir={'libsonic': 'libsonic'}, 39 | install_requires=requirements, 40 | python_requires='>=3', 41 | classifiers=[ 42 | 'Development Status :: 4 - Beta', 43 | 'Intended Audience :: System Administrators', 44 | 'Intended Audience :: Information Technology', 45 | 'License :: OSI Approved :: GNU General Public License (GPL)', 46 | 'Natural Language :: English', 47 | 'Operating System :: POSIX', 48 | 'Programming Language :: Python', 49 | 'Topic :: System :: Systems Administration', 50 | 'Topic :: Internet :: WWW/HTTP', 51 | 'Topic :: Software Development :: Libraries :: Python Modules', 52 | 'Topic :: Software Development :: Libraries', 53 | 'Topic :: System', 54 | ] 55 | ) 56 | --------------------------------------------------------------------------------