diff --git a/COPYRIGHT.md b/COPYRIGHT.md new file mode 100644 index 000000000..c9c7745bb --- /dev/null +++ b/COPYRIGHT.md @@ -0,0 +1,11 @@ +Copyright (C) +2014-2017 Mark McDowall, Keivan Beigi, Taloth Saldono and contributors +2010-2014 Mark McDowall, Keivan Beigi and contributors + +_Please refer to the git commit log for details on all contributors and their respective contributions._ + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You can find a copy of the GNU General Public License in the LICENSE.md file and . diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..2a99aeee3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,675 @@ +### GNU GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +### Preamble + +The GNU General Public License is a free, copyleft license for +software and other kinds of works. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom +to share and change all versions of a program--to make sure it remains +free software for all its users. We, the Free Software Foundation, use +the GNU General Public License for most of our software; it applies +also to any other work released this way by its authors. You can apply +it to your programs, too. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you +have certain responsibilities if you distribute copies of the +software, or if you modify it: responsibilities to respect the freedom +of others. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + +Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + +Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the +manufacturer can do so. This is fundamentally incompatible with the +aim of protecting users' freedom to change the software. The +systematic pattern of such abuse occurs in the area of products for +individuals to use, which is precisely where it is most unacceptable. +Therefore, we have designed this version of the GPL to prohibit the +practice for those products. If such problems arise substantially in +other domains, we stand ready to extend this provision to those +domains in future versions of the GPL, as needed to protect the +freedom of users. + +Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish +to avoid the special danger that patents applied to a free program +could make it effectively proprietary. To prevent this, the GPL +assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS + +#### 0. Definitions. + +"This License" refers to version 3 of the GNU General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +#### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +#### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +#### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +#### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +#### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +#### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +#### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +#### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +#### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +#### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +#### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +#### 13. Use with the GNU Affero General Public License. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + +#### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in +detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or +of any later version published by the Free Software Foundation. If the +Program does not specify a version number of the GNU General Public +License, you may choose any version ever published by the Free +Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU General Public License can be used, that proxy's public +statement of acceptance of a version permanently authorizes you to +choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +#### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +#### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +#### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +### How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + +To do so, attach the following notices to the program. It is safest to +attach them to the start of each source file to most effectively state +the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper +mail. + +If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands \`show w' and \`show c' should show the +appropriate parts of the General Public License. Of course, your +program's commands might be different; for a GUI interface, you would +use an "about box". + +You should also get your employer (if you work as a programmer) or +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. For more information on this, and how to apply and follow +the GNU GPL, see . + +The GNU General Public License does not permit incorporating your +program into proprietary programs. If your program is a subroutine +library, you may consider it more useful to permit linking proprietary +applications with the library. If this is what you want to do, use the +GNU Lesser General Public License instead of this License. But first, +please read . \ No newline at end of file diff --git a/gulp/copy.js b/gulp/copy.js index ab380855d..9962defef 100644 --- a/gulp/copy.js +++ b/gulp/copy.js @@ -25,7 +25,7 @@ gulp.task('copyHtml', function () { }); gulp.task('copyContent', function () { - return gulp.src([paths.src.content + '**/*.*', '!**/*.less']) + return gulp.src([paths.src.content + '**/*.*', '!**/*.less', '!**/*.css']) .pipe(gulp.dest(paths.dest.content)) .pipe(livereload()); }); diff --git a/gulp/less.js b/gulp/less.js index 76e04b8dc..46ea2f310 100644 --- a/gulp/less.js +++ b/gulp/less.js @@ -5,7 +5,7 @@ var postcss = require('gulp-postcss'); var sourcemaps = require('gulp-sourcemaps'); var autoprefixer = require('autoprefixer-core'); var livereload = require('gulp-livereload'); - +var cleancss = require('gulp-clean-css'); var print = require('gulp-print'); var paths = require('./paths'); var errorHandler = require('./errorHandler'); @@ -16,6 +16,10 @@ gulp.task('less', function() { paths.src.content + 'bootstrap.less', paths.src.content + 'theme.less', paths.src.content + 'overrides.less', + paths.src.content + 'bootstrap.toggle-switch.css', + paths.src.content + 'fullcalendar.css', + paths.src.content + 'Messenger/messenger.css', + paths.src.content + 'Messenger/messenger.flat.css', paths.src.root + 'Series/series.less', paths.src.root + 'Activity/activity.less', paths.src.root + 'AddSeries/addSeries.less', @@ -33,12 +37,13 @@ gulp.task('less', function() { .pipe(sourcemaps.init()) .pipe(less({ dumpLineNumbers : 'false', - compress : true, - yuicompress : true, + compress : false, + yuicompress : false, ieCompat : true, strictImports : true })) .pipe(postcss([ autoprefixer({ browsers: ['last 2 versions'] }) ])) + .pipe(cleancss()) .on('error', errorHandler.onError) .pipe(sourcemaps.write(paths.dest.content)) .pipe(gulp.dest(paths.dest.content)) diff --git a/package.json b/package.json index c3556ed7f..3d720403f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "del": "1.2.0", "gulp": "3.9.0", "gulp-cached": "1.1.0", + "gulp-clean-css": "^3.0.4", "gulp-concat": "2.6.0", "gulp-declare": "0.3.0", "gulp-handlebars": "3.0.1", diff --git a/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs b/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs index f6efc16ce..4feebbdb4 100644 --- a/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs +++ b/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs @@ -33,12 +33,12 @@ namespace NzbDrone.Api.Authentication { if (_configFileProvider.AuthenticationMethod == AuthenticationType.Forms) { - RegisterFormsAuth(pipelines); + RegisterFormsAuth(pipelines); } else if (_configFileProvider.AuthenticationMethod == AuthenticationType.Basic) { - pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Sonarr")); + pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Sonarr")); } pipelines.BeforeRequest.AddItemToEndOfPipeline((Func) RequiresAuthentication); @@ -64,10 +64,13 @@ namespace NzbDrone.Api.Authentication new DefaultHmacProvider(new PassphraseKeyGenerator(_configService.HmacPassphrase, Encoding.ASCII.GetBytes(_configService.HmacSalt))) ); + FormsAuthentication.FormsAuthenticationCookieName = "_ncfa_sonarr"; + FormsAuthentication.Enable(pipelines, new FormsAuthenticationConfiguration { RedirectUrl = _configFileProvider.UrlBase + "/login", UserMapper = _authenticationService, + Path = _configFileProvider.UrlBase, CryptographyConfiguration = cryptographyConfiguration }); } diff --git a/src/NzbDrone.Api/EpisodeFiles/EpisodeFileModule.cs b/src/NzbDrone.Api/EpisodeFiles/EpisodeFileModule.cs index c2044bda3..b061ef343 100644 --- a/src/NzbDrone.Api/EpisodeFiles/EpisodeFileModule.cs +++ b/src/NzbDrone.Api/EpisodeFiles/EpisodeFileModule.cs @@ -1,16 +1,13 @@ using System.Collections.Generic; -using System.IO; -using NLog; -using NzbDrone.Api.REST; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Tv; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Exceptions; using NzbDrone.SignalR; +using HttpStatusCode = System.Net.HttpStatusCode; namespace NzbDrone.Api.EpisodeFiles { @@ -18,27 +15,21 @@ namespace NzbDrone.Api.EpisodeFiles IHandle { private readonly IMediaFileService _mediaFileService; - private readonly IDiskProvider _diskProvider; - private readonly IRecycleBinProvider _recycleBinProvider; + private readonly IDeleteMediaFiles _mediaFileDeletionService; private readonly ISeriesService _seriesService; private readonly IQualityUpgradableSpecification _qualityUpgradableSpecification; - private readonly Logger _logger; public EpisodeFileModule(IBroadcastSignalRMessage signalRBroadcaster, IMediaFileService mediaFileService, - IDiskProvider diskProvider, - IRecycleBinProvider recycleBinProvider, + IDeleteMediaFiles mediaFileDeletionService, ISeriesService seriesService, - IQualityUpgradableSpecification qualityUpgradableSpecification, - Logger logger) + IQualityUpgradableSpecification qualityUpgradableSpecification) : base(signalRBroadcaster) { _mediaFileService = mediaFileService; - _diskProvider = diskProvider; - _recycleBinProvider = recycleBinProvider; + _mediaFileDeletionService = mediaFileDeletionService; _seriesService = seriesService; _qualityUpgradableSpecification = qualityUpgradableSpecification; - _logger = logger; GetResourceById = GetEpisodeFile; GetResourceAll = GetEpisodeFiles; UpdateResource = SetQuality; @@ -77,13 +68,15 @@ namespace NzbDrone.Api.EpisodeFiles private void DeleteEpisodeFile(int id) { var episodeFile = _mediaFileService.Get(id); - var series = _seriesService.GetSeries(episodeFile.SeriesId); - var fullPath = Path.Combine(series.Path, episodeFile.RelativePath); - var subfolder = _diskProvider.GetParentFolder(series.Path).GetRelativePath(_diskProvider.GetParentFolder(fullPath)); - _logger.Info("Deleting episode file: {0}", fullPath); - _recycleBinProvider.DeleteFile(fullPath, subfolder); - _mediaFileService.Delete(episodeFile, DeleteMediaFileReason.Manual); + if (episodeFile == null) + { + throw new NzbDroneClientException(HttpStatusCode.NotFound, "Episode file not found"); + } + + var series = _seriesService.GetSeries(episodeFile.SeriesId); + + _mediaFileDeletionService.DeleteEpisodeFile(series, episodeFile); } public void Handle(EpisodeFileAddedEvent message) diff --git a/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs b/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs index d4c1deb27..349a629a4 100644 --- a/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs +++ b/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs @@ -14,7 +14,7 @@ namespace NzbDrone.Api.Episodes { public abstract class EpisodeModuleWithSignalR : NzbDroneRestModuleWithSignalR, IHandle, - IHandle + IHandle { protected readonly IEpisodeService _episodeService; protected readonly ISeriesService _seriesService; @@ -115,9 +115,14 @@ namespace NzbDrone.Api.Episodes } } - public void Handle(EpisodeDownloadedEvent message) + public void Handle(EpisodeImportedEvent message) { - foreach (var episode in message.Episode.Episodes) + if (!message.NewDownload) + { + return; + } + + foreach (var episode in message.EpisodeInfo.Episodes) { BroadcastResourceChange(ModelAction.Updated, episode.Id); } diff --git a/src/NzbDrone.Api/Extensions/Pipelines/UrlBasePipeline.cs b/src/NzbDrone.Api/Extensions/Pipelines/UrlBasePipeline.cs new file mode 100644 index 000000000..d8c765e67 --- /dev/null +++ b/src/NzbDrone.Api/Extensions/Pipelines/UrlBasePipeline.cs @@ -0,0 +1,46 @@ +using System; +using Nancy; +using Nancy.Bootstrapper; +using Nancy.Responses; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Api.Extensions.Pipelines +{ + public class UrlBasePipeline : IRegisterNancyPipeline + { + private readonly string _urlBase; + + public UrlBasePipeline(IConfigFileProvider configFileProvider) + { + _urlBase = configFileProvider.UrlBase; + } + + public int Order => 99; + + public void Register(IPipelines pipelines) + { + if (_urlBase.IsNotNullOrWhiteSpace()) + { + pipelines.BeforeRequest.AddItemToStartOfPipeline((Func) Handle); + } + } + + private Response Handle(NancyContext context) + { + var basePath = context.Request.Url.BasePath; + + if (basePath.IsNullOrWhiteSpace()) + { + return new RedirectResponse($"{_urlBase}{context.Request.Path}{context.Request.Url.Query}"); + } + + if (_urlBase != basePath) + { + return new NotFoundResponse(); + } + + return null; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs index ae66b2aa2..8e598e2a1 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs +++ b/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Text.RegularExpressions; using Nancy; @@ -17,7 +17,7 @@ namespace NzbDrone.Api.Frontend.Mappers private readonly IAnalyticsService _analyticsService; private readonly Func _cacheBreakProviderFactory; private readonly string _indexPath; - private static readonly Regex ReplaceRegex = new Regex(@"(?:(?href|src)=\"")(?.*?(?css|js|png|ico|ics))(?:\"")(?:\s(?data-no-hash))?", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex ReplaceRegex = new Regex(@"(?:(?href|src)=\"")(?.*?(?css|js|png|ico|ics|svg))(?:\"")(?:\s(?data-no-hash))?", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static string API_KEY; private static string URL_BASE; diff --git a/src/NzbDrone.Api/Frontend/StaticResourceModule.cs b/src/NzbDrone.Api/Frontend/StaticResourceModule.cs index 7ec5fe9d8..f58667c6c 100644 --- a/src/NzbDrone.Api/Frontend/StaticResourceModule.cs +++ b/src/NzbDrone.Api/Frontend/StaticResourceModule.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Nancy.Responses; @@ -38,20 +38,6 @@ namespace NzbDrone.Api.Frontend return new NotFoundResponse(); } - //Redirect to the subfolder if the request went to the base URL - if (path.Equals("/")) - { - var urlBase = _configFileProvider.UrlBase; - - if (!string.IsNullOrEmpty(urlBase)) - { - if (Request.Url.BasePath != urlBase) - { - return new RedirectResponse(urlBase + "/"); - } - } - } - var mapper = _requestMappers.SingleOrDefault(m => m.CanHandle(path)); if (mapper != null) diff --git a/src/NzbDrone.Api/Indexers/ReleaseModule.cs b/src/NzbDrone.Api/Indexers/ReleaseModule.cs index 6a31ec0b9..0f28dc3fa 100644 --- a/src/NzbDrone.Api/Indexers/ReleaseModule.cs +++ b/src/NzbDrone.Api/Indexers/ReleaseModule.cs @@ -53,7 +53,7 @@ namespace NzbDrone.Api.Indexers private Response DownloadRelease(ReleaseResource release) { - var remoteEpisode = _remoteEpisodeCache.Find(release.Guid); + var remoteEpisode = _remoteEpisodeCache.Find(GetCacheKey(release)); if (remoteEpisode == null) { @@ -68,7 +68,7 @@ namespace NzbDrone.Api.Indexers } catch (ReleaseDownloadException ex) { - _logger.Error(ex); + _logger.Error(ex, ex.Message); throw new NzbDroneClientException(HttpStatusCode.Conflict, "Getting release from indexer failed"); } @@ -113,8 +113,14 @@ namespace NzbDrone.Api.Indexers protected override ReleaseResource MapDecision(DownloadDecision decision, int initialWeight) { - _remoteEpisodeCache.Set(decision.RemoteEpisode.Release.Guid, decision.RemoteEpisode, TimeSpan.FromMinutes(30)); - return base.MapDecision(decision, initialWeight); + var resource = base.MapDecision(decision, initialWeight); + _remoteEpisodeCache.Set(GetCacheKey(resource), decision.RemoteEpisode, TimeSpan.FromMinutes(30)); + return resource; + } + + private string GetCacheKey(ReleaseResource resource) + { + return string.Concat(resource.IndexerId, "_", resource.Guid); } } } \ No newline at end of file diff --git a/src/NzbDrone.Api/Indexers/ReleaseResource.cs b/src/NzbDrone.Api/Indexers/ReleaseResource.cs index b951b0fe0..6bf07910d 100644 --- a/src/NzbDrone.Api/Indexers/ReleaseResource.cs +++ b/src/NzbDrone.Api/Indexers/ReleaseResource.cs @@ -166,9 +166,9 @@ namespace NzbDrone.Api.Indexers model.DownloadProtocol = resource.DownloadProtocol; model.TvdbId = resource.TvdbId; model.TvRageId = resource.TvRageId; - model.PublishDate = resource.PublishDate; + model.PublishDate = resource.PublishDate.ToUniversalTime(); return model; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index cce77e637..bae3942e2 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -106,6 +106,7 @@ + diff --git a/src/NzbDrone.Common.Test/ExtensionTests/UrlExtensionsFixture.cs b/src/NzbDrone.Common.Test/ExtensionTests/UrlExtensionsFixture.cs new file mode 100644 index 000000000..eae0736dc --- /dev/null +++ b/src/NzbDrone.Common.Test/ExtensionTests/UrlExtensionsFixture.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Common.Test.ExtensionTests +{ + [TestFixture] + public class UrlExtensionsFixture + { + [TestCase("http://my.local/url")] + [TestCase("https://my.local/url")] + public void should_report_as_valid_url(string url) + { + url.IsValidUrl().Should().BeTrue(); + } + + [TestCase("")] + [TestCase(" http://my.local/url")] + [TestCase("http://my.local/url ")] + public void should_report_as_invalid_url(string url) + { + url.IsValidUrl().Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs index 23d65c322..858fe5ebc 100644 --- a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs @@ -61,7 +61,7 @@ namespace NzbDrone.Common.Test.Http response.Content.Should().NotBeNullOrWhiteSpace(); } - + [Test] public void should_execute_https_get() { @@ -144,6 +144,38 @@ namespace NzbDrone.Common.Test.Http ExceptionVerification.ExpectedErrors(0); } + [Test] + public void should_follow_redirects_to_https() + { + if (typeof(TDispatcher) == typeof(ManagedHttpDispatcher) && PlatformInfo.IsMono) + { + Assert.Ignore("Will fail on tls1.2 via managed dispatcher, ignore."); + } + + var request = new HttpRequestBuilder($"http://{_httpBinHost}/redirect-to") + .AddQueryParam("url", $"https://sonarr.tv/") + .Build(); + request.AllowAutoRedirect = true; + + var response = Subject.Get(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().Contain("Sonarr"); + + ExceptionVerification.ExpectedErrors(0); + } + + [Test] + public void should_throw_on_too_many_redirects() + { + var request = new HttpRequest($"http://{_httpBinHost}/redirect/4"); + request.AllowAutoRedirect = true; + + Assert.Throws(() => Subject.Get(request)); + + ExceptionVerification.ExpectedErrors(0); + } + [Test] public void should_send_user_agent() { @@ -407,4 +439,4 @@ namespace NzbDrone.Common.Test.Http public string Url { get; set; } public string Data { get; set; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common.Test/Http/HttpUriFixture.cs b/src/NzbDrone.Common.Test/Http/HttpUriFixture.cs index 099ab990f..dea6f64cd 100644 --- a/src/NzbDrone.Common.Test/Http/HttpUriFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpUriFixture.cs @@ -7,6 +7,13 @@ namespace NzbDrone.Common.Test.Http { public class HttpUriFixture : TestBase { + [TestCase("abc://my_host.com:8080/root/api/")] + public void should_parse(string uri) + { + var newUri = new HttpUri(uri); + newUri.FullUri.Should().Be(uri); + } + [TestCase("", "", "")] [TestCase("/", "", "/")] [TestCase("base", "", "base")] @@ -77,7 +84,7 @@ namespace NzbDrone.Common.Test.Http public void should_combine_relative_path(string basePath, string relativePath, string expected) { var newUri = new HttpUri(basePath).CombinePath(relativePath); - + newUri.FullUri.Should().Be(expected); } } diff --git a/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj b/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj index 8f80dbe36..1e5a90f3d 100644 --- a/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj +++ b/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj @@ -80,6 +80,7 @@ + diff --git a/src/NzbDrone.Common.Test/PathExtensionFixture.cs b/src/NzbDrone.Common.Test/PathExtensionFixture.cs index e3e7fb34a..ec5451029 100644 --- a/src/NzbDrone.Common.Test/PathExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/PathExtensionFixture.cs @@ -86,7 +86,7 @@ namespace NzbDrone.Common.Test { first.AsOsAgnostic().PathEquals(second.AsOsAgnostic()).Should().BeFalse(); } - + [Test] public void should_return_false_when_not_a_child() { @@ -113,6 +113,7 @@ namespace NzbDrone.Common.Test [TestCase(@"C:\Test\", @"C:\Test\mydir")] [TestCase(@"C:\Test\", @"C:\Test\mydir\")] [TestCase(@"C:\Test", @"C:\Test\30.Rock.S01E01.Pilot.avi")] + [TestCase(@"C:\", @"C:\Test\30.Rock.S01E01.Pilot.avi")] public void path_should_be_parent(string parentPath, string childPath) { parentPath.AsOsAgnostic().IsParentPath(childPath.AsOsAgnostic()).Should().BeTrue(); diff --git a/src/NzbDrone.Common/Disk/DiskTransferService.cs b/src/NzbDrone.Common/Disk/DiskTransferService.cs index 3f93c11e4..7bfe67ed1 100644 --- a/src/NzbDrone.Common/Disk/DiskTransferService.cs +++ b/src/NzbDrone.Common/Disk/DiskTransferService.cs @@ -223,7 +223,7 @@ namespace NzbDrone.Common.Disk _diskProvider.MoveFile(sourcePath, tempPath, true); try { - ClearTargetPath(targetPath, overwrite); + ClearTargetPath(sourcePath, targetPath, overwrite); _diskProvider.MoveFile(tempPath, targetPath); @@ -253,7 +253,7 @@ namespace NzbDrone.Common.Disk throw new IOException(string.Format("Destination cannot be a child of the source [{0}] => [{1}]", sourcePath, targetPath)); } - ClearTargetPath(targetPath, overwrite); + ClearTargetPath(sourcePath, targetPath, overwrite); if (mode.HasFlag(TransferMode.HardLink)) { @@ -330,7 +330,7 @@ namespace NzbDrone.Common.Disk return TransferMode.None; } - private void ClearTargetPath(string targetPath, bool overwrite) + private void ClearTargetPath(string sourcePath, string targetPath, bool overwrite) { if (_diskProvider.FileExists(targetPath)) { @@ -340,7 +340,7 @@ namespace NzbDrone.Common.Disk } else { - throw new IOException(string.Format("Destination already exists [{0}]", targetPath)); + throw new IOException(string.Format("Destination already exists. [{0}] to [{1}]", sourcePath, targetPath)); } } } @@ -590,7 +590,7 @@ namespace NzbDrone.Common.Disk private bool ShouldIgnore(FileInfo file) { - if (file.Name.StartsWith(".nfs")) + if (file.Name.StartsWith(".nfs") || file.Name == "debug.log" || file.Name.EndsWith(".socket")) { _logger.Trace("Ignoring file {0}", file.FullName); return true; diff --git a/src/NzbDrone.Common/Disk/DriveInfoMount.cs b/src/NzbDrone.Common/Disk/DriveInfoMount.cs index ac039d719..5e246313b 100644 --- a/src/NzbDrone.Common/Disk/DriveInfoMount.cs +++ b/src/NzbDrone.Common/Disk/DriveInfoMount.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.Collections.Generic; +using System.IO; using NzbDrone.Common.Extensions; namespace NzbDrone.Common.Disk @@ -8,10 +9,11 @@ namespace NzbDrone.Common.Disk private readonly DriveInfo _driveInfo; private readonly DriveType _driveType; - public DriveInfoMount(DriveInfo driveInfo, DriveType driveType = DriveType.Unknown) + public DriveInfoMount(DriveInfo driveInfo, DriveType driveType = DriveType.Unknown, MountOptions mountOptions = null) { _driveInfo = driveInfo; _driveType = driveType; + MountOptions = mountOptions; } public long AvailableFreeSpace => _driveInfo.AvailableFreeSpace; @@ -33,6 +35,8 @@ namespace NzbDrone.Common.Disk public bool IsReady => _driveInfo.IsReady; + public MountOptions MountOptions { get; private set; } + public string Name => _driveInfo.Name; public string RootDirectory => _driveInfo.RootDirectory.FullName; diff --git a/src/NzbDrone.Common/Disk/IMount.cs b/src/NzbDrone.Common/Disk/IMount.cs index 285673d69..3b15a4cb2 100644 --- a/src/NzbDrone.Common/Disk/IMount.cs +++ b/src/NzbDrone.Common/Disk/IMount.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.IO; namespace NzbDrone.Common.Disk @@ -8,6 +9,7 @@ namespace NzbDrone.Common.Disk string DriveFormat { get; } DriveType DriveType { get; } bool IsReady { get; } + MountOptions MountOptions { get; } string Name { get; } string RootDirectory { get; } long TotalFreeSpace { get; } diff --git a/src/NzbDrone.Common/Disk/MountOptions.cs b/src/NzbDrone.Common/Disk/MountOptions.cs new file mode 100644 index 000000000..749c0a739 --- /dev/null +++ b/src/NzbDrone.Common/Disk/MountOptions.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace NzbDrone.Common.Disk +{ + public class MountOptions + { + private readonly Dictionary _options; + + public MountOptions(Dictionary options) + { + _options = options; + } + + public bool IsReadOnly => _options.ContainsKey("ro"); + } +} diff --git a/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs b/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs index 7132d539f..993275aa9 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs @@ -3,6 +3,7 @@ using System.Security.AccessControl; using System.Security.Principal; using NLog; using NzbDrone.Common.Disk; +using NzbDrone.Common.Exceptions; using NzbDrone.Common.Instrumentation; namespace NzbDrone.Common.EnvironmentInfo @@ -33,6 +34,11 @@ namespace NzbDrone.Common.EnvironmentInfo { SetPermissions(); } + + if (!_diskProvider.FolderWritable(_appFolderInfo.AppDataFolder)) + { + throw new SonarrStartupException("AppFolder {0} is not writable", _appFolderInfo.AppDataFolder); + } } private void SetPermissions() diff --git a/src/NzbDrone.Common/Exceptions/SonarrStartupException.cs b/src/NzbDrone.Common/Exceptions/SonarrStartupException.cs new file mode 100644 index 000000000..b61207642 --- /dev/null +++ b/src/NzbDrone.Common/Exceptions/SonarrStartupException.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Common.Exceptions +{ + public class SonarrStartupException : NzbDroneException + { + public SonarrStartupException(string message, params object[] args) + : base("Sonarr failed to start: " + string.Format(message, args)) + { + + } + + public SonarrStartupException(string message) + : base("Sonarr failed to start: " + message) + { + + } + + public SonarrStartupException() + : base("Sonarr failed to start") + { + + } + + public SonarrStartupException(Exception innerException, string message, params object[] args) + : base("Sonarr failed to start: " + string.Format(message, args), innerException) + { + } + + public SonarrStartupException(Exception innerException, string message) + : base("Sonarr failed to start: " + message, innerException) + { + } + + public SonarrStartupException(Exception innerException) + : base("Sonarr failed to start: " + innerException.Message) + { + + } + } +} diff --git a/src/NzbDrone.Common/Extensions/ExceptionExtensions.cs b/src/NzbDrone.Common/Extensions/ExceptionExtensions.cs new file mode 100644 index 000000000..c719cef3e --- /dev/null +++ b/src/NzbDrone.Common/Extensions/ExceptionExtensions.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Common.Extensions +{ + public static class ExceptionExtensions + { + public static T WithData(this T ex, string key, string value) where T : Exception + { + ex.AddData(key, value); + + return ex; + } + public static T WithData(this T ex, string key, int value) where T : Exception + { + ex.AddData(key, value.ToString()); + + return ex; + } + + public static T WithData(this T ex, string key, Http.HttpUri value) where T : Exception + { + ex.AddData(key, value.ToString()); + + return ex; + } + + + public static T WithData(this T ex, Http.HttpResponse response, int maxSampleLength = 512) where T : Exception + { + if (response == null || response.Content == null) return ex; + + var contentSample = response.Content.Substring(0, Math.Min(response.Content.Length, 512)); + + if (response.Headers != null) + { + ex.AddData("ContentType", response.Headers.ContentType ?? string.Empty); + } + ex.AddData("ContentLength", response.Content.Length.ToString()); + ex.AddData("ContentSample", contentSample); + + return ex; + } + + + private static void AddData(this Exception ex, string key, string value) + { + if (value.IsNullOrWhiteSpace()) return; + + ex.Data[key] = value; + } + } +} diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index e03f0a594..7576d454a 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -80,11 +80,11 @@ namespace NzbDrone.Common.Extensions public static bool IsParentPath(this string parentPath, string childPath) { - if (parentPath != "/") + if (parentPath != "/" && !parentPath.EndsWith(":\\")) { parentPath = parentPath.TrimEnd(Path.DirectorySeparatorChar); } - if (childPath != "/") + if (childPath != "/" && !parentPath.EndsWith(":\\")) { childPath = childPath.TrimEnd(Path.DirectorySeparatorChar); } @@ -276,4 +276,4 @@ namespace NzbDrone.Common.Extensions return Path.Combine(appFolderInfo.StartUpFolder, NLOG_CONFIG_FILE); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Extensions/StringExtensions.cs b/src/NzbDrone.Common/Extensions/StringExtensions.cs index 247274e29..324a76eeb 100644 --- a/src/NzbDrone.Common/Extensions/StringExtensions.cs +++ b/src/NzbDrone.Common/Extensions/StringExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; @@ -63,6 +64,11 @@ namespace NzbDrone.Common.Extensions return text; } + public static string Join(this IEnumerable values, string separator) + { + return string.Join(separator, values); + } + public static string CleanSpaces(this string text) { return CollapseSpace.Replace(text, " ").Trim(); @@ -78,6 +84,16 @@ namespace NzbDrone.Common.Extensions return !string.IsNullOrWhiteSpace(text); } + public static bool StartsWithIgnoreCase(this string text, string startsWith) + { + return text.StartsWith(startsWith, StringComparison.InvariantCultureIgnoreCase); + } + + public static bool EqualsIgnoreCase(this string text, string equals) + { + return text.Equals(equals, StringComparison.InvariantCultureIgnoreCase); + } + public static bool ContainsIgnoreCase(this string text, string contains) { return text.IndexOf(contains, StringComparison.InvariantCultureIgnoreCase) > -1; @@ -118,4 +134,4 @@ namespace NzbDrone.Common.Extensions return Encoding.ASCII.GetString(new [] { byteResult }); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Extensions/UrlExtensions.cs b/src/NzbDrone.Common/Extensions/UrlExtensions.cs index b2dac6c19..50e0b9856 100644 --- a/src/NzbDrone.Common/Extensions/UrlExtensions.cs +++ b/src/NzbDrone.Common/Extensions/UrlExtensions.cs @@ -11,6 +11,11 @@ namespace NzbDrone.Common.Extensions return false; } + if (path.StartsWith(" ") || path.EndsWith(" ")) + { + return false; + } + Uri uri; if (!Uri.TryCreate(path, UriKind.Absolute, out uri)) { diff --git a/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs index 83d6fb1d1..17574982d 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs @@ -38,7 +38,7 @@ namespace NzbDrone.Common.Http.Dispatchers _caBundleFilePath = _caBundleFileName; } } - + public CurlHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, IUserAgentBuilder userAgentBuilder, Logger logger) { _proxySettingsProvider = proxySettingsProvider; @@ -107,7 +107,7 @@ namespace NzbDrone.Common.Http.Dispatchers throw new NotSupportedException($"HttpCurl method {request.Method} not supported"); } curlEasy.UserAgent = _userAgentBuilder.GetUserAgent(request.UseSimplifiedUserAgent); - curlEasy.FollowLocation = request.AllowAutoRedirect; + curlEasy.FollowLocation = false; if (request.RequestTimeout != TimeSpan.Zero) { diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 60231f75e..9841bbcb9 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -32,7 +32,7 @@ namespace NzbDrone.Common.Http.Dispatchers webRequest.Method = request.Method.ToString(); webRequest.UserAgent = _userAgentBuilder.GetUserAgent(request.UseSimplifiedUserAgent); webRequest.KeepAlive = request.ConnectionKeepAlive; - webRequest.AllowAutoRedirect = request.AllowAutoRedirect; + webRequest.AllowAutoRedirect = false; webRequest.CookieContainer = cookies; if (request.RequestTimeout != TimeSpan.Zero) @@ -73,7 +73,19 @@ namespace NzbDrone.Common.Http.Dispatchers if (httpWebResponse == null) { - throw; + // The default messages for WebException on mono are pretty horrible. + if (e.Status == WebExceptionStatus.NameResolutionFailure) + { + throw new WebException($"DNS Name Resolution Failure: '{webRequest.RequestUri.Host}'", e.Status); + } + else if (OsInfo.IsNotWindows) + { + throw new WebException($"{e.Message}: '{webRequest.RequestUri}'", e.Status); + } + else + { + throw; + } } } @@ -83,7 +95,14 @@ namespace NzbDrone.Common.Http.Dispatchers { if (responseStream != null) { - data = responseStream.ToBytes(); + try + { + data = responseStream.ToBytes(); + } + catch (Exception ex) + { + throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, httpWebResponse); + } } } diff --git a/src/NzbDrone.Common/Http/HttpClient.cs b/src/NzbDrone.Common/Http/HttpClient.cs index 849647f64..5b499c0e0 100644 --- a/src/NzbDrone.Common/Http/HttpClient.cs +++ b/src/NzbDrone.Common/Http/HttpClient.cs @@ -7,6 +7,7 @@ using System.Net; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Common.TPL; @@ -50,6 +51,57 @@ namespace NzbDrone.Common.Http } public HttpResponse Execute(HttpRequest request) + { + var autoRedirectCount = 0; + var autoRedirectChain = new List(); + autoRedirectChain.Add(request.Url.ToString()); + + var response = ExecuteRequest(request); + + while (response.StatusCode == HttpStatusCode.Moved || + response.StatusCode == HttpStatusCode.MovedPermanently || + response.StatusCode == HttpStatusCode.Found) + { + if (request.AllowAutoRedirect) + { + request.Url += new HttpUri(response.Headers.GetSingleValue("Location")); + autoRedirectChain.Add(request.Url.ToString()); + + _logger.Trace("Redirected to {0}", request.Url); + + autoRedirectCount++; + if (autoRedirectCount > 3) + { + throw new WebException($"Too many automatic redirections were attempted for {autoRedirectChain.Join(" -> ")}", WebExceptionStatus.ProtocolError); + } + + response = ExecuteRequest(request); + } + else if (!RuntimeInfo.IsProduction) + { + _logger.Error("Server requested a redirect to [{0}]. Update the request URL to avoid this redirect.", response.Headers["Location"]); + break; + } + } + + if (!request.SuppressHttpError && response.HasHttpError) + { + _logger.Warn("HTTP Error - {0}", response); + + if ((int)response.StatusCode == 429) + { + throw new TooManyRequestsException(request, response); + } + else + { + throw new HttpException(request, response); + } + } + + return response; + } + + private HttpResponse ExecuteRequest(HttpRequest request) { foreach (var interceptor in _requestInterceptors) { @@ -85,28 +137,6 @@ namespace NzbDrone.Common.Http _logger.Trace("Response content ({0} bytes): {1}", response.ResponseData.Length, response.Content); } - if (!RuntimeInfo.IsProduction && - (response.StatusCode == HttpStatusCode.Moved || - response.StatusCode == HttpStatusCode.MovedPermanently || - response.StatusCode == HttpStatusCode.Found)) - { - _logger.Error("Server requested a redirect to [{0}]. Update the request URL to avoid this redirect.", response.Headers["Location"]); - } - - if (!request.SuppressHttpError && response.HasHttpError) - { - _logger.Warn("HTTP Error - {0}", response); - - if ((int)response.StatusCode == 429) - { - throw new TooManyRequestsException(request, response); - } - else - { - throw new HttpException(request, response); - } - } - return response; } @@ -217,4 +247,4 @@ namespace NzbDrone.Common.Http return new HttpResponse(response); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Http/HttpHeader.cs b/src/NzbDrone.Common/Http/HttpHeader.cs index 88e0ab81e..8cf25489a 100644 --- a/src/NzbDrone.Common/Http/HttpHeader.cs +++ b/src/NzbDrone.Common/Http/HttpHeader.cs @@ -37,7 +37,7 @@ namespace NzbDrone.Common.Http } if (values.Length > 1) { - throw new ApplicationException(string.Format("Expected {0} to occur only once.", key)); + throw new ApplicationException($"Expected {key} to occur only once, but was {values.Join("|")}."); } return values[0]; @@ -54,7 +54,7 @@ namespace NzbDrone.Common.Http return converter(value); } protected void SetSingleValue(string key, string value) - { + { if (value == null) { Remove(key); @@ -175,4 +175,4 @@ namespace NzbDrone.Common.Http .ToList(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Http/HttpMethod.cs b/src/NzbDrone.Common/Http/HttpMethod.cs index 1fa33a823..c5f7b5307 100644 --- a/src/NzbDrone.Common/Http/HttpMethod.cs +++ b/src/NzbDrone.Common/Http/HttpMethod.cs @@ -1,13 +1,14 @@ -namespace NzbDrone.Common.Http +namespace NzbDrone.Common.Http { public enum HttpMethod { GET, - PUT, POST, - HEAD, + PUT, DELETE, + HEAD, + OPTIONS, PATCH, - OPTIONS + MERGE } } \ No newline at end of file diff --git a/src/NzbDrone.Common/Http/HttpUri.cs b/src/NzbDrone.Common/Http/HttpUri.cs index 23e47be94..cbbecd718 100644 --- a/src/NzbDrone.Common/Http/HttpUri.cs +++ b/src/NzbDrone.Common/Http/HttpUri.cs @@ -8,7 +8,7 @@ namespace NzbDrone.Common.Http { public class HttpUri : IEquatable { - private static readonly Regex RegexUri = new Regex(@"^(?:(?[a-z]+):)?(?://(?[-A-Z0-9.]+)(?::(?[0-9]{1,5}))?)?(?(?:(?:(?<=^)|/)[^/?#\r\n]+)+/?|/)?(?:\?(?[^#\r\n]*))?(?:\#(?.*))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex RegexUri = new Regex(@"^(?:(?[a-z]+):)?(?://(?[-_A-Z0-9.]+)(?::(?[0-9]{1,5}))?)?(?(?:(?:(?<=^)|/)[^/?#\r\n]+)+/?|/)?(?:\?(?[^#\r\n]*))?(?:\#(?.*))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly string _uri; public string FullUri => _uri; @@ -168,7 +168,7 @@ namespace NzbDrone.Common.Http { return basePath.Substring(0, baseSlashIndex) + "/" + relativePath; } - + return relativePath; } @@ -263,7 +263,7 @@ namespace NzbDrone.Common.Http { return new HttpUri(baseUrl.Scheme, baseUrl.Host, baseUrl.Port, CombineRelativePath(baseUrl.Path, relativeUrl.Path), relativeUrl.Query, relativeUrl.Fragment); } - + return new HttpUri(baseUrl.Scheme, baseUrl.Host, baseUrl.Port, baseUrl.Path, relativeUrl.Query, relativeUrl.Fragment); } } diff --git a/src/NzbDrone.Common/Instrumentation/CleansingJsonVisitor.cs b/src/NzbDrone.Common/Instrumentation/CleansingJsonVisitor.cs new file mode 100644 index 000000000..f33f4587b --- /dev/null +++ b/src/NzbDrone.Common/Instrumentation/CleansingJsonVisitor.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json.Linq; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Common.Instrumentation +{ + public class CleansingJsonVisitor : JsonVisitor + { + public override void Visit(JArray json) + { + for (var i = 0; i < json.Count; i++) + { + if (json[i].Type == JTokenType.String) + { + var text = json[i].Value(); + json[i] = new JValue(CleanseLogMessage.Cleanse(text)); + } + } + foreach (JToken token in json) + { + Visit(token); + } + } + + public override void Visit(JProperty property) + { + if (property.Value.Type == JTokenType.String) + { + property.Value = CleanseValue(property.Value as JValue); + } + else + { + base.Visit(property); + } + } + + private JValue CleanseValue(JValue value) + { + var text = value.Value(); + var cleansed = CleanseLogMessage.Cleanse(text); + return new JValue(cleansed); + } + } +} diff --git a/src/NzbDrone.Common/Instrumentation/Extensions/LoggerProgressExtensions.cs b/src/NzbDrone.Common/Instrumentation/Extensions/LoggerExtensions.cs similarity index 98% rename from src/NzbDrone.Common/Instrumentation/Extensions/LoggerProgressExtensions.cs rename to src/NzbDrone.Common/Instrumentation/Extensions/LoggerExtensions.cs index 5abeeb6ba..ca377e8f4 100644 --- a/src/NzbDrone.Common/Instrumentation/Extensions/LoggerProgressExtensions.cs +++ b/src/NzbDrone.Common/Instrumentation/Extensions/LoggerExtensions.cs @@ -1,4 +1,5 @@ using NLog; +using NLog.Fluent; namespace NzbDrone.Common.Instrumentation.Extensions { diff --git a/src/NzbDrone.Common/Instrumentation/Extensions/SentryLoggerExtensions.cs b/src/NzbDrone.Common/Instrumentation/Extensions/SentryLoggerExtensions.cs new file mode 100644 index 000000000..063a01439 --- /dev/null +++ b/src/NzbDrone.Common/Instrumentation/Extensions/SentryLoggerExtensions.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NLog; +using NLog.Fluent; + +namespace NzbDrone.Common.Instrumentation.Extensions +{ + public static class SentryLoggerExtensions + { + public static readonly Logger SentryLogger = LogManager.GetLogger("Sentry"); + + public static LogBuilder SentryFingerprint(this LogBuilder logBuilder, params string[] fingerprint) + { + return logBuilder.Property("Sentry", fingerprint); + } + + public static LogBuilder WriteSentryDebug(this LogBuilder logBuilder, params string[] fingerprint) + { + return LogSentryMessage(logBuilder, LogLevel.Debug, fingerprint); + } + + public static LogBuilder WriteSentryInfo(this LogBuilder logBuilder, params string[] fingerprint) + { + return LogSentryMessage(logBuilder, LogLevel.Info, fingerprint); + } + + public static LogBuilder WriteSentryWarn(this LogBuilder logBuilder, params string[] fingerprint) + { + return LogSentryMessage(logBuilder, LogLevel.Warn, fingerprint); + } + + public static LogBuilder WriteSentryError(this LogBuilder logBuilder, params string[] fingerprint) + { + return LogSentryMessage(logBuilder, LogLevel.Error, fingerprint); + } + + private static LogBuilder LogSentryMessage(LogBuilder logBuilder, LogLevel level, string[] fingerprint) + { + SentryLogger.Log(level) + .CopyLogEvent(logBuilder.LogEventInfo) + .SentryFingerprint(fingerprint) + .Write(); + + return logBuilder.Property("Sentry", null); + } + + private static LogBuilder CopyLogEvent(this LogBuilder logBuilder, LogEventInfo logEvent) + { + return logBuilder.LoggerName(logEvent.LoggerName) + .TimeStamp(logEvent.TimeStamp) + .Message(logEvent.Message, logEvent.Parameters) + .Properties((Dictionary)logEvent.Properties) + .Exception(logEvent.Exception); + } + } +} diff --git a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs index 60373b991..fa6087762 100644 --- a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs +++ b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs @@ -99,7 +99,7 @@ namespace NzbDrone.Common.Instrumentation else { dsn = RuntimeInfo.IsProduction - ? "https://3e8a38b1a4df4de8b0453a724f5a1139:5a708dd75c724b32ae5128b6a895650f@sentry.sonarr.tv/8" + ? "https://a013727b8d224e719894e1e13ff4966b:c95ca1f9ca02418d829db10c2938baf4@sentry.sonarr.tv/8" : "https://4ee3580e01d8407c96a7430fbc953512:5f2d07227a0b4fde99dea07041a3ff93@sentry.sonarr.tv/10"; } @@ -109,9 +109,13 @@ namespace NzbDrone.Common.Instrumentation Layout = "${message}" }; - var loggingRule = new LoggingRule("*", updateClient ? LogLevel.Trace : LogLevel.Error, target); + var loggingRule = new LoggingRule("*", updateClient ? LogLevel.Trace : LogLevel.Warn, target); LogManager.Configuration.AddTarget("sentryTarget", target); LogManager.Configuration.LoggingRules.Add(loggingRule); + + // Events logged to Sentry go only to Sentry. + var loggingRuleSentry = new LoggingRule("Sentry", LogLevel.Debug, target) { Final = true }; + LogManager.Configuration.LoggingRules.Insert(0, loggingRuleSentry); } private static void RegisterDebugger() diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryPacketCleanser.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryPacketCleanser.cs new file mode 100644 index 000000000..0815c0d48 --- /dev/null +++ b/src/NzbDrone.Common/Instrumentation/Sentry/SentryPacketCleanser.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json.Linq; + +namespace NzbDrone.Common.Instrumentation.Sentry +{ + public class SentryPacketCleanser + { + public void CleansePacket(SonarrSentryPacket packet) + { + packet.Message = CleanseLogMessage.Cleanse(packet.Message); + + if (packet.Fingerprint != null) + { + for (var i = 0; i < packet.Fingerprint.Length; i++) + { + packet.Fingerprint[i] = CleanseLogMessage.Cleanse(packet.Fingerprint[i]); + } + } + + if (packet.Extra != null) + { + var target = JObject.FromObject(packet.Extra); + new CleansingJsonVisitor().Visit(target); + packet.Extra = target; + } + } + } +} diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs index b0b20eeee..af629df16 100644 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs +++ b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Net; @@ -71,6 +72,11 @@ namespace NzbDrone.Common.Instrumentation.Sentry private static List GetFingerPrint(LogEventInfo logEvent) { + if (logEvent.Properties.ContainsKey("Sentry")) + { + return ((string[])logEvent.Properties["Sentry"]).ToList(); + } + var fingerPrint = new List { logEvent.Level.Ordinal.ToString(), @@ -94,13 +100,33 @@ namespace NzbDrone.Common.Instrumentation.Sentry return fingerPrint; } + private bool IsSentryMessage(LogEventInfo logEvent) + { + if (logEvent.Properties.ContainsKey("Sentry")) + { + return logEvent.Properties["Sentry"] != null; + } + + if (logEvent.Level >= LogLevel.Error && logEvent.Exception != null) + { + return true; + } + + return false; + } + protected override void Write(LogEventInfo logEvent) { + if (_unauthorized) + { + return; + } + try { // don't report non-critical events without exceptions - if (logEvent.Exception == null || _unauthorized) + if (!IsSentryMessage(logEvent)) { return; } @@ -112,8 +138,16 @@ namespace NzbDrone.Common.Instrumentation.Sentry } var extras = logEvent.Properties.ToDictionary(x => x.Key.ToString(), x => x.Value.ToString()); + extras.Remove("Sentry"); _client.Logger = logEvent.LoggerName; + if (logEvent.Exception != null) + { + foreach (DictionaryEntry data in logEvent.Exception.Data) + { + extras.Add(data.Key.ToString(), data.Value.ToString()); + } + } var sentryMessage = new SentryMessage(logEvent.Message, logEvent.Parameters); @@ -135,11 +169,16 @@ namespace NzbDrone.Common.Instrumentation.Sentry sentryEvent.Fingerprint.Add(logEvent.Exception.GetType().FullName); } + if (logEvent.Properties.ContainsKey("Sentry")) + { + sentryEvent.Fingerprint.Clear(); + Array.ForEach((string[])logEvent.Properties["Sentry"], sentryEvent.Fingerprint.Add); + } + var osName = Environment.GetEnvironmentVariable("OS_NAME"); var osVersion = Environment.GetEnvironmentVariable("OS_VERSION"); var runTimeVersion = Environment.GetEnvironmentVariable("RUNTIME_VERSION"); - sentryEvent.Tags.Add("os_name", osName); sentryEvent.Tags.Add("os_version", $"{osName} {osVersion}"); sentryEvent.Tags.Add("runtime_version", $"{PlatformInfo.PlatformName} {runTimeVersion}"); diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SonarrJsonPacketFactory.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SonarrJsonPacketFactory.cs index fb639fb2f..3ba6b499c 100644 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SonarrJsonPacketFactory.cs +++ b/src/NzbDrone.Common/Instrumentation/Sentry/SonarrJsonPacketFactory.cs @@ -6,6 +6,13 @@ namespace NzbDrone.Common.Instrumentation.Sentry { public class SonarrJsonPacketFactory : IJsonPacketFactory { + private readonly SentryPacketCleanser _cleanser; + + public SonarrJsonPacketFactory() + { + _cleanser = new SentryPacketCleanser(); + } + private static string ShortenPath(string path) { @@ -37,6 +44,8 @@ namespace NzbDrone.Common.Instrumentation.Sentry frame.Filename = ShortenPath(frame.Filename); } } + + _cleanser.CleansePacket(packet); } catch (Exception) { @@ -46,7 +55,6 @@ namespace NzbDrone.Common.Instrumentation.Sentry return packet; } - [Obsolete] public JsonPacket Create(string project, SentryMessage message, ErrorLevel level = ErrorLevel.Info, IDictionary tags = null, string[] fingerprint = null, object extra = null) @@ -61,4 +69,4 @@ namespace NzbDrone.Common.Instrumentation.Sentry throw new NotImplementedException(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj index 34ee38755..088272149 100644 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ b/src/NzbDrone.Common/NzbDrone.Common.csproj @@ -46,16 +46,14 @@ ..\packages\NLog.4.4.3\lib\net40\NLog.dll - - ..\packages\DotNet4.SocksProxy.1.3.2.0\lib\net40\Org.Mentalis.dll - True + + ..\packages\DotNet4.SocksProxy.1.3.4.0\lib\net40\Org.Mentalis.dll ..\packages\SharpRaven.2.2.0\lib\net40\SharpRaven.dll - - ..\packages\DotNet4.SocksProxy.1.3.2.0\lib\net40\SocksWebProxy.dll - True + + ..\packages\DotNet4.SocksProxy.1.3.4.0\lib\net40\SocksWebProxy.dll @@ -87,12 +85,14 @@ + + @@ -136,6 +136,7 @@ + @@ -175,12 +176,15 @@ - + + + + @@ -205,6 +209,7 @@ + diff --git a/src/NzbDrone.Common/Processes/PidFileProvider.cs b/src/NzbDrone.Common/Processes/PidFileProvider.cs index c04ff445f..04cca6527 100644 --- a/src/NzbDrone.Common/Processes/PidFileProvider.cs +++ b/src/NzbDrone.Common/Processes/PidFileProvider.cs @@ -2,6 +2,7 @@ using System.IO; using NLog; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Exceptions; namespace NzbDrone.Common.Processes { @@ -38,7 +39,7 @@ namespace NzbDrone.Common.Processes catch (Exception ex) { _logger.Error(ex, "Unable to write PID file {0}", filename); - throw; + throw new SonarrStartupException(ex, "Unable to write PID file {0}", filename); } } } diff --git a/src/NzbDrone.Common/Processes/ProcessProvider.cs b/src/NzbDrone.Common/Processes/ProcessProvider.cs index 49e61c621..0ddab771f 100644 --- a/src/NzbDrone.Common/Processes/ProcessProvider.cs +++ b/src/NzbDrone.Common/Processes/ProcessProvider.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using NLog; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Model; namespace NzbDrone.Common.Processes @@ -129,7 +130,25 @@ namespace NzbDrone.Common.Processes { foreach (DictionaryEntry environmentVariable in environmentVariables) { - startInfo.EnvironmentVariables.Add(environmentVariable.Key.ToString(), environmentVariable.Value.ToString()); + try + { + _logger.Trace("Setting environment variable '{0}' to '{1}'", environmentVariable.Key, environmentVariable.Value); + startInfo.EnvironmentVariables.Add(environmentVariable.Key.ToString(), environmentVariable.Value.ToString()); + } + catch (Exception e) + { + if (environmentVariable.Value == null) + { + _logger.Error(e, "Unable to set environment variable '{0}', value is null", environmentVariable.Key); + } + + else + { + _logger.Error(e, "Unable to set environment variable '{0}'", environmentVariable.Key); + } + + throw; + } } } diff --git a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs index db7edc31b..7fbaa9ec8 100644 --- a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs +++ b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs @@ -60,6 +60,11 @@ namespace NzbDrone.Common.Reflection return (T)attribute; } + public static T[] GetAttributes(this MemberInfo member) where T : Attribute + { + return member.GetCustomAttributes(typeof(T), false).OfType().ToArray(); + } + public static Type FindTypeByName(this Assembly assembly, string name) { return assembly.GetTypes().SingleOrDefault(c => c.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)); @@ -70,4 +75,4 @@ namespace NzbDrone.Common.Reflection return type.GetCustomAttributes(typeof(TAttribute), true).Any(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Security/SecurityProtocolPolicy.cs b/src/NzbDrone.Common/Security/SecurityProtocolPolicy.cs index 03fcb97d2..04392e20e 100644 --- a/src/NzbDrone.Common/Security/SecurityProtocolPolicy.cs +++ b/src/NzbDrone.Common/Security/SecurityProtocolPolicy.cs @@ -1,6 +1,8 @@ using System; using System.Net; using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation; namespace NzbDrone.Common.Security @@ -14,6 +16,17 @@ namespace NzbDrone.Common.Security public static void Register() { + if (OsInfo.IsNotWindows) + { + // This was never meant to be used on mono, and will cause issues with mono 5 and higher if btls is enabled. + // Instead, force TLS provider to legacy for now due to conflict between btls and mediainfo, unless the user explicitly specified it. + if (Environment.GetEnvironmentVariable("MONO_TLS_PROVIDER").IsNullOrWhiteSpace()) + { + Environment.SetEnvironmentVariable("MONO_TLS_PROVIDER", "legacy"); + } + return; + } + try { // TODO: In v3 we should drop support for SSL3 because its very insecure. Only leaving it enabled because some people might rely on it. diff --git a/src/NzbDrone.Common/Serializer/JsonVisitor.cs b/src/NzbDrone.Common/Serializer/JsonVisitor.cs new file mode 100644 index 000000000..87fdeeeec --- /dev/null +++ b/src/NzbDrone.Common/Serializer/JsonVisitor.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json.Linq; + +namespace NzbDrone.Common.Serializer +{ + + public class JsonVisitor + { + protected void Dispatch(JToken json) + { + switch (json.Type) + { + case JTokenType.Object: + Visit(json as JObject); + break; + + case JTokenType.Array: + Visit(json as JArray); + break; + + case JTokenType.Raw: + Visit(json as JRaw); + break; + + case JTokenType.Constructor: + Visit(json as JConstructor); + break; + + case JTokenType.Property: + Visit(json as JProperty); + break; + + case JTokenType.Comment: + case JTokenType.Integer: + case JTokenType.Float: + case JTokenType.String: + case JTokenType.Boolean: + case JTokenType.Null: + case JTokenType.Undefined: + case JTokenType.Date: + case JTokenType.Bytes: + case JTokenType.Guid: + case JTokenType.Uri: + case JTokenType.TimeSpan: + Visit(json as JValue); + break; + + default: + break; + } + } + + public virtual void Visit(JToken json) + { + Dispatch(json); + } + + public virtual void Visit(JContainer json) + { + Dispatch(json); + } + + public virtual void Visit(JArray json) + { + foreach (JToken token in json) + { + Visit(token); + } + } + public virtual void Visit(JConstructor json) + { + } + + public virtual void Visit(JObject json) + { + foreach (JProperty property in json.Properties()) + { + Visit(property); + } + } + + public virtual void Visit(JProperty property) + { + Visit(property.Value); + } + + public virtual void Visit(JValue value) + { + + } + } +} diff --git a/src/NzbDrone.Common/packages.config b/src/NzbDrone.Common/packages.config index c3f5e4d62..67c6af5c5 100644 --- a/src/NzbDrone.Common/packages.config +++ b/src/NzbDrone.Common/packages.config @@ -1,6 +1,6 @@  - + diff --git a/src/NzbDrone.Console/ConsoleApp.cs b/src/NzbDrone.Console/ConsoleApp.cs index 6f935887f..d67ee5a5e 100644 --- a/src/NzbDrone.Console/ConsoleApp.cs +++ b/src/NzbDrone.Console/ConsoleApp.cs @@ -2,6 +2,7 @@ using System.Net.Sockets; using NLog; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Exceptions; using NzbDrone.Common.Instrumentation; using NzbDrone.Host; @@ -11,6 +12,14 @@ namespace NzbDrone.Console { private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(ConsoleApp)); + private enum ExitCodes : int + { + Normal = 0, + UnknownFailure = 1, + RecoverableFailure = 2, + NonRecoverableFailure = 3 + } + public static void Main(string[] args) { try @@ -19,30 +28,61 @@ namespace NzbDrone.Console NzbDroneLogger.Register(startupArgs, false, true); Bootstrap.Start(startupArgs, new ConsoleAlerts()); } - catch (SocketException exception) + catch (SonarrStartupException ex) { System.Console.WriteLine(""); System.Console.WriteLine(""); - Logger.Fatal(exception.Message + ". This can happen if another instance of Sonarr is already running another application is using the same port (default: 8989) or the user has insufficient permissions"); - System.Console.WriteLine("Press enter to exit..."); - System.Console.ReadLine(); - Environment.Exit(1); + Logger.Fatal(ex, "EPIC FAIL!"); + Exit(ExitCodes.NonRecoverableFailure); } - catch (Exception e) + catch (SocketException ex) { System.Console.WriteLine(""); System.Console.WriteLine(""); - Logger.Fatal(e, "EPIC FAIL!"); - System.Console.WriteLine("Press enter to exit..."); - System.Console.ReadLine(); - Environment.Exit(1); + Logger.Fatal(ex.Message + ". This can happen if another instance of Sonarr is already running another application is using the same port (default: 8989) or the user has insufficient permissions"); + Exit(ExitCodes.RecoverableFailure); + } + catch (Exception ex) + { + System.Console.WriteLine(""); + System.Console.WriteLine(""); + Logger.Fatal(ex, "EPIC FAIL!"); + Exit(ExitCodes.UnknownFailure); } Logger.Info("Exiting main."); + Exit(ExitCodes.Normal); + } + + private static void Exit(ExitCodes exitCode) + { + LogManager.Flush(); + + if (exitCode != ExitCodes.Normal) + { + System.Console.WriteLine("Press enter to exit..."); + + System.Threading.Thread.Sleep(1000); + + if (exitCode == ExitCodes.NonRecoverableFailure) + { + System.Console.WriteLine("Non-recoverable failure, waiting for user intervention..."); + for (int i = 0; i < 3600; i++) + { + System.Threading.Thread.Sleep(1000); + + if (System.Console.KeyAvailable) break; + } + } + + // Please note that ReadLine silently succeeds if there is no console, KeyAvailable does not. + System.Console.ReadLine(); + } + //Need this to terminate on mono (thanks nlog) LogManager.Configuration = null; - Environment.Exit(0); + Environment.Exit((int)exitCode); } } } diff --git a/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingServiceFixture.cs b/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingServiceFixture.cs index b94578c32..1f1a6ff8e 100644 --- a/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingServiceFixture.cs +++ b/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingServiceFixture.cs @@ -20,11 +20,14 @@ namespace NzbDrone.Core.Test.DataAugmentation.Scene private Mock _provider1; private Mock _provider2; - + [SetUp] public void Setup() { - _fakeMappings = Builder.CreateListOfSize(5).BuildListOfNew(); + _fakeMappings = Builder.CreateListOfSize(5) + .All() + .With(v => v.FilterRegex = null) + .BuildListOfNew(); _fakeMappings[0].SearchTerm = "Words"; _fakeMappings[1].SearchTerm = "That"; @@ -193,7 +196,7 @@ namespace NzbDrone.Core.Test.DataAugmentation.Scene Mocker.GetMock().Setup(c => c.All()).Returns(mappings); var tvdbId = Subject.FindTvdbId(parseTitle); - var seasonNumber = Subject.GetSceneSeasonNumber(parseTitle); + var seasonNumber = Subject.GetSceneSeasonNumber(parseTitle, null); tvdbId.Should().Be(100); seasonNumber.Should().Be(expectedSeasonNumber); @@ -314,6 +317,49 @@ namespace NzbDrone.Core.Test.DataAugmentation.Scene Subject.GetSceneNames(100, new List { 4 }, new List { 4 }).Should().BeEmpty(); } + [Test] + public void should_filter_by_regex() + { + var mappings = new List + { + new SceneMapping { Title = "Amareto", ParseTerm = "amareto", SearchTerm = "Amareto", TvdbId = 100 }, + new SceneMapping { Title = "Amareto", ParseTerm = "amareto", SearchTerm = "Amareto", TvdbId = 101, FilterRegex="-Viva$" } + }; + + Mocker.GetMock().Setup(c => c.All()).Returns(mappings); + + Subject.FindTvdbId("Amareto", "Amareto.S01E01.720p.WEB-DL-Viva").Should().Be(101); + Subject.FindTvdbId("Amareto", "Amareto.S01E01.720p.WEB-DL-DMO").Should().Be(100); + } + + [Test] + public void should_throw_if_multiple_mappings() + { + var mappings = new List + { + new SceneMapping { Title = "Amareto", ParseTerm = "amareto", SearchTerm = "Amareto", TvdbId = 100 }, + new SceneMapping { Title = "Amareto", ParseTerm = "amareto", SearchTerm = "Amareto", TvdbId = 101 } + }; + + Mocker.GetMock().Setup(c => c.All()).Returns(mappings); + + Assert.Throws(() => Subject.FindTvdbId("Amareto", "Amareto.S01E01.720p.WEB-DL-Viva")); + } + + [Test] + public void should_not_throw_if_multiple_mappings_with_same_tvdbid() + { + var mappings = new List + { + new SceneMapping { Title = "Amareto", ParseTerm = "amareto", SearchTerm = "Amareto", TvdbId = 100 }, + new SceneMapping { Title = "Amareto", ParseTerm = "amareto", SearchTerm = "Amareto", TvdbId = 100 } + }; + + Mocker.GetMock().Setup(c => c.All()).Returns(mappings); + + Subject.FindTvdbId("Amareto", "Amareto.S01E01.720p.WEB-DL-Viva").Should().Be(100); + } + private void AssertNoUpdate() { _provider1.Verify(c => c.GetSceneMappings(), Times.Once()); diff --git a/src/NzbDrone.Core.Test/DataAugmentation/SceneNumbering/XemServiceFixture.cs b/src/NzbDrone.Core.Test/DataAugmentation/SceneNumbering/XemServiceFixture.cs index 3f263c6dd..5ad5e40e8 100644 --- a/src/NzbDrone.Core.Test/DataAugmentation/SceneNumbering/XemServiceFixture.cs +++ b/src/NzbDrone.Core.Test/DataAugmentation/SceneNumbering/XemServiceFixture.cs @@ -5,6 +5,7 @@ using FizzWare.NBuilder; using FluentAssertions; using Moq; using NUnit.Framework; +using NzbDrone.Common.Extensions; using NzbDrone.Core.DataAugmentation.Xem; using NzbDrone.Core.DataAugmentation.Xem.Model; using NzbDrone.Core.Test.Framework; @@ -98,7 +99,6 @@ namespace NzbDrone.Core.Test.DataAugmentation.SceneNumbering }); } - [Test] public void should_not_fetch_scenenumbering_if_not_listed() { @@ -308,5 +308,19 @@ namespace NzbDrone.Core.Test.DataAugmentation.SceneNumbering episode.SceneSeasonNumber.Should().NotHaveValue(); episode.SceneEpisodeNumber.Should().NotHaveValue(); } + + [Test] + public void should_skip_mapping_when_scene_information_is_all_zero() + { + GivenTvdbMappings(); + + AddTvdbMapping(0, 0, 0, 8, 3, 1); // 3x01 -> 3x01 + AddTvdbMapping(0, 0, 0, 9, 3, 2); // 3x02 -> 3x02 + + Subject.Handle(new SeriesUpdatedEvent(_series)); + + Mocker.GetMock() + .Verify(v => v.UpdateEpisodes(It.Is>(e => e.Any(c => c.SceneAbsoluteEpisodeNumber == 0 && c.SceneSeasonNumber == 0 && c.SceneEpisodeNumber == 0))), Times.Never()); + } } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/BlockedIndexerSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/BlockedIndexerSpecificationFixture.cs new file mode 100644 index 000000000..7af962a96 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/BlockedIndexerSpecificationFixture.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + [TestFixture] + + public class BlockedIndexerSpecificationFixture : CoreTest + { + private RemoteEpisode _remoteEpisode; + + [SetUp] + public void Setup() + { + _remoteEpisode = new RemoteEpisode + { + Release = new ReleaseInfo { IndexerId = 1 } + }; + + Mocker.GetMock() + .Setup(v => v.GetBlockedProviders()) + .Returns(new List()); + } + + private void WithBlockedIndexer() + { + Mocker.GetMock() + .Setup(v => v.GetBlockedProviders()) + .Returns(new List { new IndexerStatus { ProviderId = 1, DisabledTill = DateTime.UtcNow } }); + } + + [Test] + public void should_return_true_if_no_blocked_indexer() + { + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_if_blocked_indexer() + { + WithBlockedIndexer(); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.Type.Should().Be(RejectionType.Temporary); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs index 0206abbd2..c9225540f 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs @@ -28,6 +28,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private Mock _fail2; private Mock _fail3; + private Mock _failDelayed1; + [SetUp] public void Setup() { @@ -39,14 +41,19 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _fail2 = new Mock(); _fail3 = new Mock(); + _failDelayed1 = new Mock(); + _pass1.Setup(c => c.IsSatisfiedBy(It.IsAny(), null)).Returns(Decision.Accept); _pass2.Setup(c => c.IsSatisfiedBy(It.IsAny(), null)).Returns(Decision.Accept); _pass3.Setup(c => c.IsSatisfiedBy(It.IsAny(), null)).Returns(Decision.Accept); - + _fail1.Setup(c => c.IsSatisfiedBy(It.IsAny(), null)).Returns(Decision.Reject("fail1")); _fail2.Setup(c => c.IsSatisfiedBy(It.IsAny(), null)).Returns(Decision.Reject("fail2")); _fail3.Setup(c => c.IsSatisfiedBy(It.IsAny(), null)).Returns(Decision.Reject("fail3")); + _failDelayed1.Setup(c => c.IsSatisfiedBy(It.IsAny(), null)).Returns(Decision.Reject("failDelayed1")); + _failDelayed1.SetupGet(c => c.Priority).Returns(SpecificationPriority.Disk); + _reports = new List { new ReleaseInfo { Title = "The.Office.S03E115.DVDRip.XviD-OSiTV" } }; _remoteEpisode = new RemoteEpisode { Series = new Series(), @@ -78,6 +85,25 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _pass3.Verify(c => c.IsSatisfiedBy(_remoteEpisode, null), Times.Once()); } + [Test] + public void should_call_delayed_specifications_if_non_delayed_passed() + { + GivenSpecifications(_pass1, _failDelayed1); + + Subject.GetRssDecision(_reports).ToList(); + _failDelayed1.Verify(c => c.IsSatisfiedBy(_remoteEpisode, null), Times.Once()); + } + + [Test] + public void should_not_call_delayed_specifications_if_non_delayed_failed() + { + GivenSpecifications(_fail1, _failDelayed1); + + Subject.GetRssDecision(_reports).ToList(); + + _failDelayed1.Verify(c => c.IsSatisfiedBy(_remoteEpisode, null), Times.Never()); + } + [Test] public void should_return_rejected_if_single_specs_fail() { @@ -214,10 +240,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests var criteria = new SeasonSearchCriteria { Episodes = episodes.Take(1).ToList(), SeasonNumber = 1 }; - var reports = episodes.Select(v => - new ReleaseInfo() - { - Title = string.Format("{0}.S{1:00}E{2:00}.720p.WEB-DL-DRONE", series.Title, v.SceneSeasonNumber, v.SceneEpisodeNumber) + var reports = episodes.Select(v => + new ReleaseInfo() + { + Title = string.Format("{0}.S{1:00}E{2:00}.720p.WEB-DL-DRONE", series.Title, v.SceneSeasonNumber, v.SceneEpisodeNumber) }).ToList(); Mocker.GetMock() @@ -289,4 +315,4 @@ namespace NzbDrone.Core.Test.DecisionEngineTests ExceptionVerification.ExpectedErrors(1); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/MonitoredEpisodeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/MonitoredEpisodeSpecificationFixture.cs index 36a46337d..f7208a2c7 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/MonitoredEpisodeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/MonitoredEpisodeSpecificationFixture.cs @@ -137,5 +137,17 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithFirstEpisodeUnmonitored(); _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultSingle, new SingleEpisodeSearchCriteria{ MonitoredEpisodesOnly = true}).Accepted.Should().BeFalse(); } + + [Test] + public void should_return_false_if_all_episodes_are_not_monitored_for_season_pack_release() + { + WithSecondEpisodeUnmonitored(); + _parseResultMulti.ParsedEpisodeInfo = new ParsedEpisodeInfo + { + FullSeason = true + }; + + _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RawDiskSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RawDiskSpecificationFixture.cs index 024c3763b..014225692 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RawDiskSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RawDiskSpecificationFixture.cs @@ -19,7 +19,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { _remoteEpisode = new RemoteEpisode { - Release = new ReleaseInfo() { DownloadProtocol = DownloadProtocol.Torrent } + Release = new ReleaseInfo + { + Title = "Series.title.s01e01", + DownloadProtocol = DownloadProtocol.Torrent + } }; } @@ -29,7 +33,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests } [Test] - public void should_return_true_if_no_container_specified() + public void should_return_true_if_no_container_specified_and_does_not_match_disc_release_pattern() { Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } @@ -69,5 +73,15 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } + [TestCase("How the Earth Was Made S02 Disc 1 1080i Blu-ray DTS-HD MA 2.0 AVC-TrollHD")] + [TestCase("The Universe S03 Disc 1 1080p Blu-ray LPCM 2.0 AVC-TrollHD")] + [TestCase("HELL ON WHEELS S02 1080P FULL BLURAY AVC DTS-HD MA 5 1")] + [TestCase("Game.of.Thrones.S06.2016.DISC.3.BluRay.1080p.AVC.Atmos.TrueHD7.1-MTeam")] + [TestCase("Game of Thrones S05 Disc 1 BluRay 1080p AVC Atmos TrueHD 7 1-MTeam")] + public void should_return_false_if_matches_disc_format(string title) + { + _remoteEpisode.Release.Title = title; + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedEpisodeFileSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedEpisodeFileSpecificationFixture.cs new file mode 100644 index 000000000..a1e3691a7 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedEpisodeFileSpecificationFixture.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.DecisionEngine.Specifications.RssSync; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; +using NzbDrone.Core.DecisionEngine; + +using NzbDrone.Core.Test.Framework; +using NzbDrone.Common.Disk; +using Moq; +using NzbDrone.Test.Common; +using System.IO; + +namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync +{ + [TestFixture] + public class DeletedEpisodeFileSpecificationFixture : CoreTest + { + private RemoteEpisode _parseResultMulti; + private RemoteEpisode _parseResultSingle; + private EpisodeFile _firstFile; + private EpisodeFile _secondFile; + + [SetUp] + public void Setup() + { + _firstFile = new EpisodeFile + { + Id = 1, + RelativePath = "My.Series.S01E01.mkv", + Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 1)), + DateAdded = DateTime.Now + }; + _secondFile = new EpisodeFile + { + Id = 2, + RelativePath = "My.Series.S01E02.mkv", + Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 1)), + DateAdded = DateTime.Now + }; + + var singleEpisodeList = new List { new Episode { EpisodeFile = _firstFile, EpisodeFileId = 1 } }; + var doubleEpisodeList = new List { + new Episode { EpisodeFile = _firstFile, EpisodeFileId = 1 }, + new Episode { EpisodeFile = _secondFile, EpisodeFileId = 2 } + }; + + var fakeSeries = Builder.CreateNew() + .With(c => c.Profile = new Profile { Cutoff = Quality.Bluray1080p }) + .With(c => c.Path = @"C:\Series\My.Series".AsOsAgnostic()) + .Build(); + + _parseResultMulti = new RemoteEpisode + { + Series = fakeSeries, + ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)) }, + Episodes = doubleEpisodeList + }; + + _parseResultSingle = new RemoteEpisode + { + Series = fakeSeries, + ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)) }, + Episodes = singleEpisodeList + }; + + GivenUnmonitorDeletedEpisodes(true); + } + + private void GivenUnmonitorDeletedEpisodes(bool enabled) + { + Mocker.GetMock() + .SetupGet(v => v.AutoUnmonitorPreviouslyDownloadedEpisodes) + .Returns(enabled); + } + + private void WithExistingFile(EpisodeFile episodeFile) + { + var path = Path.Combine(@"C:\Series\My.Series".AsOsAgnostic(), episodeFile.RelativePath); + + Mocker.GetMock() + .Setup(v => v.FileExists(path)) + .Returns(true); + } + + [Test] + public void should_return_true_when_unmonitor_deleted_episdes_is_off() + { + GivenUnmonitorDeletedEpisodes(false); + + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_searching() + { + Subject.IsSatisfiedBy(_parseResultSingle, new SeasonSearchCriteria()).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_file_exists() + { + WithExistingFile(_firstFile); + + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_if_file_is_missing() + { + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_true_if_both_of_multiple_episode_exist() + { + WithExistingFile(_firstFile); + WithExistingFile(_secondFile); + + Subject.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_if_one_of_multiple_episode_is_missing() + { + WithExistingFile(_firstFile); + + Subject.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/Search/TorrentSeedingSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/Search/TorrentSeedingSpecificationFixture.cs new file mode 100644 index 000000000..56c4ac84f --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/Search/TorrentSeedingSpecificationFixture.cs @@ -0,0 +1,111 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine.Specifications.Search; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.TorrentRss; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.DecisionEngineTests.Search +{ + [TestFixture] + public class TorrentSeedingSpecificationFixture : TestBase + { + private Series _series; + private RemoteEpisode _remoteEpisode; + private IndexerDefinition _indexerDefinition; + + [SetUp] + public void Setup() + { + _series = Builder.CreateNew().With(s => s.Id = 1).Build(); + + _remoteEpisode = new RemoteEpisode + { + Series = _series, + Release = new TorrentInfo + { + IndexerId = 1, + Title = "Series.Title.S01.720p.BluRay.X264-RlsGrp", + Seeders = 0 + } + }; + + _indexerDefinition = new IndexerDefinition + { + Settings = new TorrentRssIndexerSettings { MinimumSeeders = 5 } + }; + + Mocker.GetMock() + .Setup(v => v.Get(1)) + .Returns(_indexerDefinition); + + } + + private void GivenReleaseSeeders(int? seeders) + { + (_remoteEpisode.Release as TorrentInfo).Seeders = seeders; + } + + [Test] + public void should_return_true_if_not_torrent() + { + _remoteEpisode.Release = new ReleaseInfo + { + IndexerId = 1, + Title = "Series.Title.S01.720p.BluRay.X264-RlsGrp" + }; + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_indexer_not_specified() + { + _remoteEpisode.Release.IndexerId = 0; + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_indexer_no_longer_exists() + { + Mocker.GetMock() + .Setup(v => v.Get(It.IsAny())) + .Callback(i => { throw new ModelNotFoundException(typeof(IndexerDefinition), i); }); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_seeds_unknown() + { + GivenReleaseSeeders(null); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [TestCase(5)] + [TestCase(6)] + public void should_return_true_if_seeds_above_or_equal_to_limit(int seeders) + { + GivenReleaseSeeders(seeders); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [TestCase(0)] + [TestCase(4)] + public void should_return_false_if_seeds_belove_limit(int seeders) + { + GivenReleaseSeeders(seeders); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs index 76d22d669..6100e7590 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs @@ -6,7 +6,9 @@ using Moq; using NUnit.Framework; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; @@ -35,7 +37,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests .Build(); } - private RemoteEpisode GetRemoteEpisode(List episodes, QualityModel quality) + private RemoteEpisode GetRemoteEpisode(List episodes, QualityModel quality, DownloadProtocol downloadProtocol = DownloadProtocol.Usenet) { var remoteEpisode = new RemoteEpisode(); remoteEpisode.ParsedEpisodeInfo = new ParsedEpisodeInfo(); @@ -45,6 +47,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests remoteEpisode.Episodes.AddRange(episodes); remoteEpisode.Release = new ReleaseInfo(); + remoteEpisode.Release.DownloadProtocol = downloadProtocol; remoteEpisode.Release.PublishDate = DateTime.UtcNow; remoteEpisode.Series = Builder.CreateNew() @@ -192,7 +195,6 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests var decisions = new List(); decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary))); - decisions.Add(new DownloadDecision(remoteEpisode)); Subject.ProcessDecisions(decisions); Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Never()); @@ -209,7 +211,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary))); Subject.ProcessDecisions(decisions); - Mocker.GetMock().Verify(v => v.Add(It.IsAny()), Times.Never()); + Mocker.GetMock().Verify(v => v.Add(It.IsAny(), It.IsAny()), Times.Never()); } [Test] @@ -223,7 +225,43 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary))); Subject.ProcessDecisions(decisions); - Mocker.GetMock().Verify(v => v.Add(It.IsAny()), Times.Exactly(2)); + Mocker.GetMock().Verify(v => v.Add(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public void should_add_to_failed_if_already_failed_for_that_protocol() + { + var episodes = new List { GetEpisode(1) }; + var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p)); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteEpisode)); + decisions.Add(new DownloadDecision(remoteEpisode)); + + Mocker.GetMock().Setup(s => s.DownloadReport(It.IsAny())) + .Throws(new DownloadClientUnavailableException("Download client failed")); + + Subject.ProcessDecisions(decisions); + Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once()); + } + + [Test] + public void should_not_add_to_failed_if_failed_for_a_different_protocol() + { + var episodes = new List { GetEpisode(1) }; + var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p), DownloadProtocol.Usenet); + var remoteEpisode2 = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p), DownloadProtocol.Torrent); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteEpisode)); + decisions.Add(new DownloadDecision(remoteEpisode2)); + + Mocker.GetMock().Setup(s => s.DownloadReport(It.Is(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet))) + .Throws(new DownloadClientUnavailableException("Download client failed")); + + Subject.ProcessDecisions(decisions); + Mocker.GetMock().Verify(v => v.DownloadReport(It.Is(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)), Times.Once()); + Mocker.GetMock().Verify(v => v.DownloadReport(It.Is(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent)), Times.Once()); } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientStatusServiceFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientStatusServiceFixture.cs new file mode 100644 index 000000000..08aca1cdb --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientStatusServiceFixture.cs @@ -0,0 +1,156 @@ +using System; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Download +{ + public class DownloadClientStatusServiceFixture : CoreTest + { + private DateTime _epoch; + + [SetUp] + public void SetUp() + { + _epoch = DateTime.UtcNow; + } + + private DownloadClientStatus WithStatus(DownloadClientStatus status) + { + Mocker.GetMock() + .Setup(v => v.FindByProviderId(1)) + .Returns(status); + + Mocker.GetMock() + .Setup(v => v.All()) + .Returns(new[] { status }); + + return status; + } + + private void VerifyUpdate() + { + Mocker.GetMock() + .Verify(v => v.Upsert(It.IsAny()), Times.Once()); + } + + private void VerifyNoUpdate() + { + Mocker.GetMock() + .Verify(v => v.Upsert(It.IsAny()), Times.Never()); + } + + [Test] + public void should_not_consider_blocked_within_5_minutes_since_initial_failure() + { + WithStatus(new DownloadClientStatus + { + InitialFailure = _epoch - TimeSpan.FromMinutes(4), + MostRecentFailure = _epoch - TimeSpan.FromSeconds(4), + EscalationLevel = 3 + }); + + Subject.RecordFailure(1); + + VerifyUpdate(); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().BeNull(); + } + + [Test] + public void should_consider_blocked_after_5_minutes_since_initial_failure() + { + WithStatus(new DownloadClientStatus + { + InitialFailure = _epoch - TimeSpan.FromMinutes(6), + MostRecentFailure = _epoch - TimeSpan.FromSeconds(120), + EscalationLevel = 3 + }); + + Subject.RecordFailure(1); + + VerifyUpdate(); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().NotBeNull(); + } + + [Test] + public void should_not_escalate_further_till_after_5_minutes_since_initial_failure() + { + var origStatus = WithStatus(new DownloadClientStatus + { + InitialFailure = _epoch - TimeSpan.FromMinutes(4), + MostRecentFailure = _epoch - TimeSpan.FromSeconds(4), + EscalationLevel = 3 + }); + + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().BeNull(); + + origStatus.EscalationLevel.Should().Be(3); + } + + [Test] + public void should_escalate_further_after_5_minutes_since_initial_failure() + { + WithStatus(new DownloadClientStatus + { + InitialFailure = _epoch - TimeSpan.FromMinutes(6), + MostRecentFailure = _epoch - TimeSpan.FromSeconds(120), + EscalationLevel = 3 + }); + + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().NotBeNull(); + + status.EscalationLevel.Should().BeGreaterThan(3); + } + + [Test] + public void should_not_escalate_beyond_3_hours() + { + WithStatus(new DownloadClientStatus + { + InitialFailure = _epoch - TimeSpan.FromMinutes(6), + MostRecentFailure = _epoch - TimeSpan.FromSeconds(120), + EscalationLevel = 3 + }); + + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().NotBeNull(); + status.DisabledTill.Should().HaveValue(); + status.DisabledTill.Should().NotBeAfter(_epoch + TimeSpan.FromHours(3.1)); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/ScanWatchFolderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/ScanWatchFolderFixture.cs index 199b206e2..f4fbe8580 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/ScanWatchFolderFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/ScanWatchFolderFixture.cs @@ -1,15 +1,17 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; using NzbDrone.Core.Download; -using NzbDrone.Test.Common; -using System.Threading; -using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Download.Clients.Blackhole; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole { @@ -18,7 +20,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole { protected readonly string _title = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE"; protected string _completedDownloadFolder = @"c:\blackhole\completed".AsOsAgnostic(); - + protected void GivenCompletedItem() { var targetDir = Path.Combine(_completedDownloadFolder, _title); @@ -33,6 +35,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole Mocker.GetMock() .Setup(c => c.GetFileSize(It.IsAny())) .Returns(1000000); + + Mocker.GetMock().Setup(c => c.FilterFiles(It.IsAny(), It.IsAny>())) + .Returns>((b, s) => s.ToList()); } protected void GivenChangedItem() @@ -43,7 +48,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole .Setup(c => c.GetFileSize(It.IsAny())) .Returns(currentSize + 1); } - + private void VerifySingleItem(DownloadItemStatus status) { var items = Subject.GetItems(_completedDownloadFolder, TimeSpan.FromMilliseconds(50)).ToList(); diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/TorrentBlackholeFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/TorrentBlackholeFixture.cs index 5a61271cf..a992c886e 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/TorrentBlackholeFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/TorrentBlackholeFixture.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; @@ -10,6 +11,7 @@ using NzbDrone.Common.Http; using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients.Blackhole; using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.TorrentInfo; using NzbDrone.Core.Parser.Model; using NzbDrone.Test.Common; @@ -48,6 +50,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole Mocker.GetMock() .Setup(c => c.GetHashFromTorrentFile(It.IsAny())) .Returns("myhash"); + + Mocker.GetMock().Setup(c => c.FilterFiles(It.IsAny(), It.IsAny>())) + .Returns>((b, s) => s.ToList()); } protected void GivenFailedDownload() @@ -99,6 +104,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole var result = Subject.GetItems().Single(); VerifyCompleted(result); + + result.CanBeRemoved.Should().BeFalse(); + result.CanMoveFiles.Should().BeFalse(); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs index d48d9e0b8..ff48c1370 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs @@ -1,5 +1,6 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; @@ -10,6 +11,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Http; using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients.Blackhole; +using NzbDrone.Core.MediaFiles; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole @@ -41,6 +43,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole Mocker.GetMock() .Setup(c => c.OpenWriteStream(It.IsAny())) .Returns(() => new FileStream(GetTempFilePath(), FileMode.Create)); + + Mocker.GetMock().Setup(c => c.FilterFiles(It.IsAny(), It.IsAny>())) + .Returns>((b, s) => s.ToList()); } protected void GivenFailedDownload() @@ -77,6 +82,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole var result = Subject.GetItems().Single(); VerifyCompleted(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DelugeTests/DelugeFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DelugeTests/DelugeFixture.cs index af24f2797..f9bbc2ed0 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DelugeTests/DelugeFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DelugeTests/DelugeFixture.cs @@ -19,6 +19,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests protected DelugeTorrent _downloading; protected DelugeTorrent _failed; protected DelugeTorrent _completed; + protected DelugeTorrent _seeding; [SetUp] public void Setup() @@ -75,7 +76,11 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests Size = 1000, BytesDownloaded = 1000, Progress = 100.0, - DownloadPath = "somepath" + DownloadPath = "somepath", + IsAutoManaged = true, + StopAtRatio = true, + StopRatio = 1.0, + Ratio = 1.5 }; Mocker.GetMock() @@ -114,7 +119,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951".ToLower()) .Callback(PrepareClientToReturnQueuedItem); } - + protected virtual void GivenTorrents(List torrents) { if (torrents == null) @@ -129,7 +134,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests protected void PrepareClientToReturnQueuedItem() { - GivenTorrents(new List + GivenTorrents(new List { _queued }); @@ -137,7 +142,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests protected void PrepareClientToReturnDownloadingItem() { - GivenTorrents(new List + GivenTorrents(new List { _downloading }); @@ -145,7 +150,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests protected void PrepareClientToReturnFailedItem() { - GivenTorrents(new List + GivenTorrents(new List { _failed }); @@ -189,6 +194,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests PrepareClientToReturnCompletedItem(); var item = Subject.GetItems().Single(); VerifyCompleted(item); + + item.CanBeRemoved.Should().BeTrue(); + item.CanMoveFiles.Should().BeTrue(); } [Test] @@ -248,11 +256,11 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests item.Status.Should().Be(expectedItemStatus); } - [TestCase(DelugeTorrentStatus.Paused, DownloadItemStatus.Completed, true)] - [TestCase(DelugeTorrentStatus.Checking, DownloadItemStatus.Downloading, true)] - [TestCase(DelugeTorrentStatus.Queued, DownloadItemStatus.Completed, true)] - [TestCase(DelugeTorrentStatus.Seeding, DownloadItemStatus.Completed, true)] - public void GetItems_should_return_completed_item_as_downloadItemStatus(string apiStatus, DownloadItemStatus expectedItemStatus, bool expectedReadOnly) + [TestCase(DelugeTorrentStatus.Paused, DownloadItemStatus.Completed)] + [TestCase(DelugeTorrentStatus.Checking, DownloadItemStatus.Downloading)] + [TestCase(DelugeTorrentStatus.Queued, DownloadItemStatus.Completed)] + [TestCase(DelugeTorrentStatus.Seeding, DownloadItemStatus.Completed)] + public void GetItems_should_return_completed_item_as_downloadItemStatus(string apiStatus, DownloadItemStatus expectedItemStatus) { _completed.State = apiStatus; @@ -261,24 +269,25 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests var item = Subject.GetItems().Single(); item.Status.Should().Be(expectedItemStatus); - item.IsReadOnly.Should().Be(expectedReadOnly); } - [Test] - public void GetItems_should_check_share_ratio_for_readonly() + [TestCase(0.5, false)] + [TestCase(1.01, true)] + public void GetItems_should_check_share_ratio_for_moveFiles_and_remove(double ratio, bool canBeRemoved) { _completed.State = DelugeTorrentStatus.Paused; _completed.IsAutoManaged = true; _completed.StopAtRatio = true; _completed.StopRatio = 1.0; - _completed.Ratio = 1.01; + _completed.Ratio = ratio; PrepareClientToReturnCompletedItem(); var item = Subject.GetItems().Single(); item.Status.Should().Be(DownloadItemStatus.Completed); - item.IsReadOnly.Should().BeFalse(); + item.CanMoveFiles.Should().Be(canBeRemoved); + item.CanBeRemoved.Should().Be(canBeRemoved); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/TorrentDownloadStationFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/TorrentDownloadStationFixture.cs index 8269acda6..4142ddc65 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/TorrentDownloadStationFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/TorrentDownloadStationFixture.cs @@ -576,11 +576,11 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests items.Should().OnlyContain(v => !v.OutputPath.IsEmpty); } - [TestCase(DownloadStationTaskStatus.Downloading, DownloadItemStatus.Downloading, true)] - [TestCase(DownloadStationTaskStatus.Finished, DownloadItemStatus.Completed, false)] - [TestCase(DownloadStationTaskStatus.Seeding, DownloadItemStatus.Completed, true)] - [TestCase(DownloadStationTaskStatus.Waiting, DownloadItemStatus.Queued, true)] - public void GetItems_should_return_readonly_expected(DownloadStationTaskStatus apiStatus, DownloadItemStatus expectedItemStatus, bool readOnlyExpected) + [TestCase(DownloadStationTaskStatus.Downloading, false, false)] + [TestCase(DownloadStationTaskStatus.Finished, true, true)] + [TestCase(DownloadStationTaskStatus.Seeding, true, false)] + [TestCase(DownloadStationTaskStatus.Waiting, false, false)] + public void GetItems_should_return_canBeMoved_and_canBeDeleted_as_expected(DownloadStationTaskStatus apiStatus, bool canMoveFilesExpected, bool canBeRemovedExpected) { GivenSerialNumber(); GivenSharedFolder(); @@ -592,7 +592,11 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests var items = Subject.GetItems(); items.Should().HaveCount(1); - items.First().IsReadOnly.Should().Be(readOnlyExpected); + + var item = items.First(); + + item.CanBeRemoved.Should().Be(canBeRemovedExpected); + item.CanMoveFiles.Should().Be(canMoveFilesExpected); } [TestCase(DownloadStationTaskStatus.Downloading, DownloadItemStatus.Downloading)] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/UsenetDownloadStationFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/UsenetDownloadStationFixture.cs index 48df65841..6d0a0f0e9 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/UsenetDownloadStationFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/UsenetDownloadStationFixture.cs @@ -408,24 +408,6 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests items.Should().OnlyContain(v => !v.OutputPath.IsEmpty); } - [TestCase(DownloadStationTaskStatus.Downloading, DownloadItemStatus.Downloading, true)] - [TestCase(DownloadStationTaskStatus.Finished, DownloadItemStatus.Completed, false)] - [TestCase(DownloadStationTaskStatus.Waiting, DownloadItemStatus.Queued, true)] - public void GetItems_should_return_readonly_expected(DownloadStationTaskStatus apiStatus, DownloadItemStatus expectedItemStatus, bool readOnlyExpected) - { - GivenSerialNumber(); - GivenSharedFolder(); - - _queued.Status = apiStatus; - - GivenTasks(new List() { _queued }); - - var items = Subject.GetItems(); - - items.Should().HaveCount(1); - items.First().IsReadOnly.Should().Be(readOnlyExpected); - } - [TestCase(DownloadStationTaskStatus.Downloading, DownloadItemStatus.Downloading)] [TestCase(DownloadStationTaskStatus.Error, DownloadItemStatus.Failed)] [TestCase(DownloadStationTaskStatus.Extracting, DownloadItemStatus.Downloading)] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/HadoukenTests/HadoukenFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/HadoukenTests/HadoukenFixture.cs index adcffe633..5762fdf08 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/HadoukenTests/HadoukenFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/HadoukenTests/HadoukenFixture.cs @@ -190,6 +190,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests PrepareClientToReturnCompletedItem(); var item = Subject.GetItems().Single(); VerifyCompleted(item); + + item.CanBeRemoved.Should().BeTrue(); + item.CanMoveFiles.Should().BeTrue(); } [Test] @@ -298,7 +301,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests .Returns("hash"); var result = Subject.Download(remoteEpisode); - + Assert.IsFalse(result.Any(c => char.IsLower(c))); } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbVortexTests/NzbVortexFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbVortexTests/NzbVortexFixture.cs index ccdaba3f1..c18bb7b94 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbVortexTests/NzbVortexFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbVortexTests/NzbVortexFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using FluentAssertions; @@ -103,10 +103,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests public void queued_item_should_have_required_properties() { GivenQueue(_queued); - + var result = Subject.GetItems().Single(); VerifyQueued(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -118,6 +121,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests var result = Subject.GetItems().Single(); VerifyPaused(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -129,6 +135,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests var result = Subject.GetItems().Single(); VerifyDownloading(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -139,6 +148,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests var result = Subject.GetItems().Single(); VerifyCompleted(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -149,6 +161,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests var result = Subject.GetItems().Single(); VerifyFailed(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs index 226288464..feea1329d 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using FluentAssertions; @@ -19,6 +19,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests private NzbgetQueueItem _queued; private NzbgetHistoryItem _failed; private NzbgetHistoryItem _completed; + private Dictionary _configItems; [SetUp] public void Setup() @@ -80,13 +81,18 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests DownloadRate = 7000000 }); - var configItems = new Dictionary(); - configItems.Add("Category1.Name", "tv"); - configItems.Add("Category1.DestDir", @"/remote/mount/tv"); + + Mocker.GetMock() + .Setup(v => v.GetVersion(It.IsAny())) + .Returns("14.0"); + + _configItems = new Dictionary(); + _configItems.Add("Category1.Name", "tv"); + _configItems.Add("Category1.DestDir", @"/remote/mount/tv"); Mocker.GetMock() .Setup(v => v.GetConfig(It.IsAny())) - .Returns(configItems); + .Returns(_configItems); } protected void GivenFailedDownload() @@ -163,10 +169,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests GivenQueue(_queued); GivenHistory(null); - + var result = Subject.GetItems().Single(); VerifyQueued(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -180,6 +189,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests var result = Subject.GetItems().Single(); VerifyPaused(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -193,6 +205,25 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests var result = Subject.GetItems().Single(); VerifyDownloading(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); + } + + [Test] + public void post_processing_item_should_have_required_properties() + { + _queued.ActiveDownloads = 1; + + GivenQueue(_queued); + GivenHistory(null); + + _queued.RemainingSizeLo = 0; + + var result = Subject.GetItems().Single(); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -204,6 +235,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests var result = Subject.GetItems().Single(); VerifyCompleted(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -386,5 +420,18 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests error.IsValid.Should().Be(expected); } + + [TestCase("0", false)] + [TestCase("1", true)] + [TestCase(" 7", false)] + [TestCase("5000000", false)] + public void should_test_keephistory(string keephistory, bool expected) + { + _configItems["KeepHistory"] = keephistory; + + var error = Subject.Test(); + + error.IsValid.Should().Be(expected); + } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs index 3ceece6f6..d7146acbe 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs @@ -89,6 +89,12 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests }); } + protected void GivenHighPriority() + { + Subject.Definition.Settings.As().OlderTvPriority = (int)QBittorrentPriority.First; + Subject.Definition.Settings.As().RecentTvPriority = (int)QBittorrentPriority.First; + } + protected void GivenMaxRatio(float maxRatio, bool removeOnMaxRatio = true) { Mocker.GetMock() @@ -265,6 +271,39 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests id.Should().Be(expectedHash); } + [Test] + public void Download_should_set_top_priority() + { + GivenHighPriority(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + Mocker.GetMock() + .Verify(v => v.MoveTorrentToTopInQueue(It.IsAny(), It.IsAny()), Times.Once()); + } + + [Test] + public void Download_should_not_fail_if_top_priority_not_available() + { + GivenHighPriority(); + GivenSuccessfulDownload(); + + Mocker.GetMock() + .Setup(v => v.MoveTorrentToTopInQueue(It.IsAny(), It.IsAny())) + .Throws(new HttpException(new HttpResponse(new HttpRequest("http://me.local/"), new HttpHeader(), new byte[0], System.Net.HttpStatusCode.Forbidden))); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + + ExceptionVerification.ExpectedWarns(1); + } + [Test] public void should_return_status_with_outputdirs() { @@ -311,7 +350,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests } [Test] - public void should_be_read_only_if_max_ratio_not_reached() + public void should_not_be_removable_and_should_not_allow_move_files_if_max_ratio_not_reached() { GivenMaxRatio(1.0f); @@ -330,11 +369,12 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests GivenTorrents(new List { torrent }); var item = Subject.GetItems().Single(); - item.IsReadOnly.Should().BeTrue(); + item.CanBeRemoved.Should().BeFalse(); + item.CanMoveFiles.Should().BeFalse(); } [Test] - public void should_be_read_only_if_max_ratio_reached_and_not_paused() + public void should_not_be_removable_and_should_not_allow_move_files_if_max_ratio_reached_and_not_paused() { GivenMaxRatio(1.0f); @@ -353,11 +393,12 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests GivenTorrents(new List { torrent }); var item = Subject.GetItems().Single(); - item.IsReadOnly.Should().BeTrue(); + item.CanBeRemoved.Should().BeFalse(); + item.CanMoveFiles.Should().BeFalse(); } [Test] - public void should_be_read_only_if_max_ratio_is_not_set() + public void should_not_be_removable_and_should_not_allow_move_files_if_max_ratio_is_not_set() { GivenMaxRatio(1.0f, false); @@ -376,11 +417,12 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests GivenTorrents(new List { torrent }); var item = Subject.GetItems().Single(); - item.IsReadOnly.Should().BeTrue(); + item.CanBeRemoved.Should().BeFalse(); + item.CanMoveFiles.Should().BeFalse(); } [Test] - public void should_not_be_read_only_if_max_ratio_reached_and_paused() + public void should_be_removable_and_should_allow_move_files_if_max_ratio_reached_and_paused() { GivenMaxRatio(1.0f); @@ -399,7 +441,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests GivenTorrents(new List { torrent }); var item = Subject.GetItems().Single(); - item.IsReadOnly.Should().BeFalse(); + item.CanBeRemoved.Should().BeTrue(); + item.CanMoveFiles.Should().BeTrue(); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs index e58e4b9a8..b3c1dc680 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using FizzWare.NBuilder; @@ -191,7 +191,10 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests var result = Subject.GetItems().Single(); VerifyQueued(result); + result.RemainingTime.Should().NotBe(TimeSpan.Zero); + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [TestCase(SabnzbdDownloadStatus.Paused)] @@ -205,6 +208,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests var result = Subject.GetItems().Single(); VerifyPaused(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [TestCase(SabnzbdDownloadStatus.Checking)] @@ -227,7 +233,10 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests var result = Subject.GetItems().Single(); VerifyDownloading(result); + result.RemainingTime.Should().NotBe(TimeSpan.Zero); + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -239,6 +248,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests var result = Subject.GetItems().Single(); VerifyCompleted(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -252,6 +264,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests var result = Subject.GetItems().Single(); VerifyFailed(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs index 39ec56789..032fe15ff 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs @@ -41,6 +41,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests PrepareClientToReturnCompletedItem(); var item = Subject.GetItems().Single(); VerifyCompleted(item); + + item.CanBeRemoved.Should().BeTrue(); + item.CanMoveFiles.Should().BeTrue(); } [Test] @@ -145,8 +148,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests [TestCase(TransmissionTorrentStatus.Check, DownloadItemStatus.Downloading)] [TestCase(TransmissionTorrentStatus.Queued, DownloadItemStatus.Queued)] [TestCase(TransmissionTorrentStatus.Downloading, DownloadItemStatus.Downloading)] - [TestCase(TransmissionTorrentStatus.SeedingWait, DownloadItemStatus.Completed)] - [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Completed)] + [TestCase(TransmissionTorrentStatus.SeedingWait, DownloadItemStatus.Downloading)] + [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Downloading)] public void GetItems_should_return_queued_item_as_downloadItemStatus(TransmissionTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus) { _queued.Status = apiStatus; @@ -160,7 +163,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests [TestCase(TransmissionTorrentStatus.Queued, DownloadItemStatus.Queued)] [TestCase(TransmissionTorrentStatus.Downloading, DownloadItemStatus.Downloading)] - [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Completed)] + [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Downloading)] public void GetItems_should_return_downloading_item_as_downloadItemStatus(TransmissionTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus) { _downloading.Status = apiStatus; @@ -172,13 +175,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests item.Status.Should().Be(expectedItemStatus); } - [TestCase(TransmissionTorrentStatus.Stopped, DownloadItemStatus.Completed, false)] - [TestCase(TransmissionTorrentStatus.CheckWait, DownloadItemStatus.Downloading, true)] - [TestCase(TransmissionTorrentStatus.Check, DownloadItemStatus.Downloading, true)] - [TestCase(TransmissionTorrentStatus.Queued, DownloadItemStatus.Completed, true)] - [TestCase(TransmissionTorrentStatus.SeedingWait, DownloadItemStatus.Completed, true)] - [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Completed, true)] - public void GetItems_should_return_completed_item_as_downloadItemStatus(TransmissionTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus, bool expectedReadOnly) + [TestCase(TransmissionTorrentStatus.Stopped, DownloadItemStatus.Completed, true)] + [TestCase(TransmissionTorrentStatus.CheckWait, DownloadItemStatus.Downloading, false)] + [TestCase(TransmissionTorrentStatus.Check, DownloadItemStatus.Downloading, false)] + [TestCase(TransmissionTorrentStatus.Queued, DownloadItemStatus.Completed, false)] + [TestCase(TransmissionTorrentStatus.SeedingWait, DownloadItemStatus.Completed, false)] + [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Completed, false)] + public void GetItems_should_return_completed_item_as_downloadItemStatus(TransmissionTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus, bool expectedValue) { _completed.Status = apiStatus; @@ -187,7 +190,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests var item = Subject.GetItems().Single(); item.Status.Should().Be(expectedItemStatus); - item.IsReadOnly.Should().Be(expectedReadOnly); + item.CanBeRemoved.Should().Be(expectedValue); + item.CanMoveFiles.Should().Be(expectedValue); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/UTorrentTests/UTorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/UTorrentTests/UTorrentFixture.cs index 1d9f037d2..e66ce42dc 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/UTorrentTests/UTorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/UTorrentTests/UTorrentFixture.cs @@ -222,6 +222,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests PrepareClientToReturnCompletedItem(); var item = Subject.GetItems().Single(); VerifyCompleted(item); + + item.CanBeRemoved.Should().BeTrue(); + item.CanMoveFiles.Should().BeTrue(); } [Test] @@ -292,12 +295,12 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests item.Status.Should().Be(expectedItemStatus); } - [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checking, DownloadItemStatus.Queued, false)] - [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked, DownloadItemStatus.Completed, false)] - [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked | UTorrentTorrentStatus.Queued, DownloadItemStatus.Completed, true)] - [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked | UTorrentTorrentStatus.Started, DownloadItemStatus.Completed, true)] - [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked | UTorrentTorrentStatus.Queued | UTorrentTorrentStatus.Paused, DownloadItemStatus.Completed, true)] - public void GetItems_should_return_completed_item_as_downloadItemStatus(UTorrentTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus, bool expectedReadOnly) + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checking, DownloadItemStatus.Queued, true)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked, DownloadItemStatus.Completed, true)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked | UTorrentTorrentStatus.Queued, DownloadItemStatus.Completed, false)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked | UTorrentTorrentStatus.Started, DownloadItemStatus.Completed, false)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked | UTorrentTorrentStatus.Queued | UTorrentTorrentStatus.Paused, DownloadItemStatus.Completed, false)] + public void GetItems_should_return_completed_item_as_downloadItemStatus(UTorrentTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus, bool expectedValue) { _completed.Status = apiStatus; @@ -306,7 +309,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests var item = Subject.GetItems().Single(); item.Status.Should().Be(expectedItemStatus); - item.IsReadOnly.Should().Be(expectedReadOnly); + item.CanBeRemoved.Should().Be(expectedValue); + item.CanMoveFiles.Should().Be(expectedValue); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/VuzeTests/VuzeFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/VuzeTests/VuzeFixture.cs index 00278c811..406e70e15 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/VuzeTests/VuzeFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/VuzeTests/VuzeFixture.cs @@ -13,6 +13,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests [TestFixture] public class VuzeFixture : TransmissionFixtureBase { + [SetUp] + public void Setup_Vuze() + { + // Vuze never sets isFinished. + _completed.IsFinished = false; + } + [Test] public void queued_item_should_have_required_properties() { @@ -43,6 +50,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests PrepareClientToReturnCompletedItem(); var item = Subject.GetItems().Single(); VerifyCompleted(item); + + item.CanBeRemoved.Should().BeTrue(); + item.CanMoveFiles.Should().BeTrue(); } [Test] @@ -147,8 +157,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests [TestCase(TransmissionTorrentStatus.Check, DownloadItemStatus.Downloading)] [TestCase(TransmissionTorrentStatus.Queued, DownloadItemStatus.Queued)] [TestCase(TransmissionTorrentStatus.Downloading, DownloadItemStatus.Downloading)] - [TestCase(TransmissionTorrentStatus.SeedingWait, DownloadItemStatus.Completed)] - [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Completed)] + [TestCase(TransmissionTorrentStatus.SeedingWait, DownloadItemStatus.Downloading)] + [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Downloading)] public void GetItems_should_return_queued_item_as_downloadItemStatus(TransmissionTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus) { _queued.Status = apiStatus; @@ -162,7 +172,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests [TestCase(TransmissionTorrentStatus.Queued, DownloadItemStatus.Queued)] [TestCase(TransmissionTorrentStatus.Downloading, DownloadItemStatus.Downloading)] - [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Completed)] + [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Downloading)] public void GetItems_should_return_downloading_item_as_downloadItemStatus(TransmissionTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus) { _downloading.Status = apiStatus; @@ -174,13 +184,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests item.Status.Should().Be(expectedItemStatus); } - [TestCase(TransmissionTorrentStatus.Stopped, DownloadItemStatus.Completed, false)] - [TestCase(TransmissionTorrentStatus.CheckWait, DownloadItemStatus.Downloading, true)] - [TestCase(TransmissionTorrentStatus.Check, DownloadItemStatus.Downloading, true)] - [TestCase(TransmissionTorrentStatus.Queued, DownloadItemStatus.Completed, true)] - [TestCase(TransmissionTorrentStatus.SeedingWait, DownloadItemStatus.Completed, true)] - [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Completed, true)] - public void GetItems_should_return_completed_item_as_downloadItemStatus(TransmissionTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus, bool expectedReadOnly) + [TestCase(TransmissionTorrentStatus.Stopped, DownloadItemStatus.Completed, true)] + [TestCase(TransmissionTorrentStatus.CheckWait, DownloadItemStatus.Downloading, false)] + [TestCase(TransmissionTorrentStatus.Check, DownloadItemStatus.Downloading, false)] + [TestCase(TransmissionTorrentStatus.Queued, DownloadItemStatus.Queued, false)] + [TestCase(TransmissionTorrentStatus.SeedingWait, DownloadItemStatus.Completed, false)] + [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Completed, false)] + public void GetItems_should_return_completed_item_as_downloadItemStatus(TransmissionTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus, bool expectedValue) { _completed.Status = apiStatus; @@ -189,7 +199,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests var item = Subject.GetItems().Single(); item.Status.Should().Be(expectedItemStatus); - item.IsReadOnly.Should().Be(expectedReadOnly); + item.CanBeRemoved.Should().Be(expectedValue); + item.CanMoveFiles.Should().Be(expectedValue); } [Test] @@ -294,7 +305,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests } [Test] - public void should_have_correct_output_directory() + public void should_have_correct_output_directory_for_multifile_torrents() { WindowsOnly(); @@ -311,5 +322,25 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests items.First().OutputPath.Should().Be(@"C:\Downloads\" + _title); } + [Test] + public void should_have_correct_output_directory_for_singlefile_torrents() + { + WindowsOnly(); + + var fileName = _title + ".mkv"; + _downloading.Name = fileName; + _downloading.DownloadDir = @"C:/Downloads"; + + GivenTorrents(new List + { + _downloading + }); + + var items = Subject.GetItems().ToList(); + + items.Should().HaveCount(1); + items.First().OutputPath.Should().Be(@"C:\Downloads\" + fileName); + } + } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs index 2a5a29c6b..717ed3b57 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs @@ -102,7 +102,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests [Test] public void should_add() { - Subject.Add(_temporarilyRejected); + Subject.Add(_temporarilyRejected, PendingReleaseReason.Delay); VerifyInsert(); } @@ -112,7 +112,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { GivenHeldRelease(_release.Title, _release.Indexer, _release.PublishDate); - Subject.Add(_temporarilyRejected); + Subject.Add(_temporarilyRejected, PendingReleaseReason.Delay); VerifyNoInsert(); } @@ -122,7 +122,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { GivenHeldRelease(_release.Title + "-RP", _release.Indexer, _release.PublishDate); - Subject.Add(_temporarilyRejected); + Subject.Add(_temporarilyRejected, PendingReleaseReason.Delay); VerifyInsert(); } @@ -132,7 +132,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { GivenHeldRelease(_release.Title, "AnotherIndexer", _release.PublishDate); - Subject.Add(_temporarilyRejected); + Subject.Add(_temporarilyRejected, PendingReleaseReason.Delay); VerifyInsert(); } @@ -142,7 +142,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { GivenHeldRelease(_release.Title, _release.Indexer, _release.PublishDate.AddHours(1)); - Subject.Add(_temporarilyRejected); + Subject.Add(_temporarilyRejected, PendingReleaseReason.Delay); VerifyInsert(); } diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/PendingReleaseServiceFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/PendingReleaseServiceFixture.cs index 37f979ba9..8ff0f0ea0 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/PendingReleaseServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/PendingReleaseServiceFixture.cs @@ -27,7 +27,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests public void should_not_ignore_pending_items_from_available_indexer() { Mocker.GetMock() - .Setup(v => v.GetBlockedIndexers()) + .Setup(v => v.GetBlockedProviders()) .Returns(new List()); GivenPendingRelease(); @@ -43,8 +43,8 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests public void should_ignore_pending_items_from_unavailable_indexer() { Mocker.GetMock() - .Setup(v => v.GetBlockedIndexers()) - .Returns(new List { new IndexerStatus { IndexerId = 1, DisabledTill = DateTime.UtcNow.AddHours(2) } }); + .Setup(v => v.GetBlockedProviders()) + .Returns(new List { new IndexerStatus { ProviderId = 1, DisabledTill = DateTime.UtcNow.AddHours(2) } }); GivenPendingRelease(); diff --git a/src/NzbDrone.Core.Test/Files/Indexers/Torznab/torznab_animetosho.xml b/src/NzbDrone.Core.Test/Files/Indexers/Torznab/torznab_animetosho.xml new file mode 100644 index 000000000..94505a443 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Indexers/Torznab/torznab_animetosho.xml @@ -0,0 +1,60 @@ + + + + + Anime Tosho + https://localhost/ + Latest releases feed + en-gb + 30 + Wed, 17 May 2017 20:36:06 +0000 + + + [finFAGs]_Frame_Arms_Girl_07_(1280x720_TV_AAC)_[1262B6F7].mkv + Wed, 17 May 2017 20:36:06 +0000 + https://localhost/view/123451 + Anime + Total Size: 301.8 MB
]]>
+ https://localhost/view/finfags-_frame_arms_girl_07_-1280x720_tv_aac-_-1262b6f7-mkv.123451 + https://localhost/view/finfags-_frame_arms_girl_07_-1280x720_tv_aac-_-1262b6f7-mkv.123451 + + TokyoTosho + + + + + + + + + + + + +
+ + [HorribleSubs] Frame Arms Girl - 07 [720p].mkv + Mon, 15 May 2017 19:15:56 +0000 + https://localhost/view/123452 + Anime + Total Size: 452.0 MB
]]>
+ https://localhost/view/horriblesubs-frame-arms-girl-07-720p-mkv.123452 + https://localhost/view/horriblesubs-frame-arms-girl-07-720p-mkv.123452 + + + TokyoTosho + + + + + + + + + + + + +
+
+
diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientCheckFixture.cs index dc6986d79..665038e32 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientCheckFixture.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using NUnit.Framework; using NzbDrone.Core.Download; using NzbDrone.Core.HealthCheck.Checks; -using NzbDrone.Core.Indexers; using NzbDrone.Core.Test.Framework; using NzbDrone.Test.Common; @@ -26,7 +25,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks public void should_return_error_when_download_client_throws() { var downloadClient = Mocker.GetMock(); - downloadClient.Setup(s => s.Definition).Returns(new IndexerDefinition{Name = "Test"}); + downloadClient.Setup(s => s.Definition).Returns(new DownloadClientDefinition{Name = "Test"}); downloadClient.Setup(s => s.GetItems()) .Throws(); @@ -36,8 +35,6 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks .Returns(new IDownloadClient[] { downloadClient.Object }); Subject.Check().ShouldBeError(); - - ExceptionVerification.ExpectedErrors(1); } [Test] diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs index 6592e2a76..50beaae7e 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs @@ -22,7 +22,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks .Returns(_indexers); Mocker.GetMock() - .Setup(v => v.GetBlockedIndexers()) + .Setup(v => v.GetBlockedProviders()) .Returns(_blockedIndexers); } @@ -40,7 +40,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks { _blockedIndexers.Add(new IndexerStatus { - IndexerId = id, + ProviderId = id, InitialFailure = DateTime.UtcNow.AddHours(-failureHours), MostRecentFailure = DateTime.UtcNow.AddHours(-0.1), EscalationLevel = 5, @@ -57,13 +57,6 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks { Subject.Check().ShouldBeOk(); } - [Test] - public void should_not_return_error_when_indexer_failed_less_than_an_hour() - { - GivenIndexer(1, 0.1, 0.5); - - Subject.Check().ShouldBeOk(); - } [Test] public void should_return_warning_if_indexer_unavailable() diff --git a/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs b/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs index c2d436ec8..660a58d6f 100644 --- a/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs +++ b/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs @@ -13,6 +13,7 @@ using NzbDrone.Core.Qualities; using System.Collections.Generic; using NzbDrone.Core.Test.Qualities; using FluentAssertions; +using NzbDrone.Core.Download; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Test.HistoryTests @@ -81,10 +82,16 @@ namespace NzbDrone.Core.Test.HistoryTests Path = @"C:\Test\Unsorted\Series.s01e01.mkv" }; - Subject.Handle(new EpisodeImportedEvent(localEpisode, episodeFile, true, "sab", "abcd", true)); + var downloadClientItem = new DownloadClientItem + { + DownloadClient = "sab", + DownloadId = "abcd" + }; + + Subject.Handle(new EpisodeImportedEvent(localEpisode, episodeFile, new List(), true, downloadClientItem)); Mocker.GetMock() .Verify(v => v.Insert(It.Is(h => h.SourceTitle == Path.GetFileNameWithoutExtension(localEpisode.Path)))); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupDownloadClientUnavailablePendingReleasesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupDownloadClientUnavailablePendingReleasesFixture.cs new file mode 100644 index 000000000..9d5b35ed3 --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupDownloadClientUnavailablePendingReleasesFixture.cs @@ -0,0 +1,60 @@ +using System; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Housekeeping.Housekeepers; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class CleanupDownloadClientUnavailablePendingReleasesFixture : DbTest + { + [Test] + public void should_delete_old_DownloadClientUnavailable_pending_items() + { + var pendingRelease = Builder.CreateNew() + .With(h => h.Reason = PendingReleaseReason.DownloadClientUnavailable) + .With(h => h.Added = DateTime.UtcNow.AddDays(-21)) + .With(h => h.ParsedEpisodeInfo = new ParsedEpisodeInfo()) + .With(h => h.Release = new ReleaseInfo()) + .BuildNew(); + + Db.Insert(pendingRelease); + Subject.Clean(); + AllStoredModels.Should().BeEmpty(); + } + + [Test] + public void should_delete_old_Fallback_pending_items() + { + var pendingRelease = Builder.CreateNew() + .With(h => h.Reason = PendingReleaseReason.Fallback) + .With(h => h.Added = DateTime.UtcNow.AddDays(-21)) + .With(h => h.ParsedEpisodeInfo = new ParsedEpisodeInfo()) + .With(h => h.Release = new ReleaseInfo()) + .BuildNew(); + + Db.Insert(pendingRelease); + Subject.Clean(); + AllStoredModels.Should().BeEmpty(); + } + + [Test] + public void should_not_delete_old_Delay_pending_items() + { + var pendingRelease = Builder.CreateNew() + .With(h => h.Reason = PendingReleaseReason.Delay) + .With(h => h.Added = DateTime.UtcNow.AddDays(-21)) + .With(h => h.ParsedEpisodeInfo = new ParsedEpisodeInfo()) + .With(h => h.Release = new ReleaseInfo()) + .BuildNew(); + + Db.Insert(pendingRelease); + Subject.Clean(); + AllStoredModels.Should().HaveCount(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatusFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatusFixture.cs index 189c1672d..c5e757188 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatusFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatusFixture.cs @@ -28,7 +28,7 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers public void should_delete_orphaned_indexerstatus() { var status = Builder.CreateNew() - .With(h => h.IndexerId = _indexer.Id) + .With(h => h.ProviderId = _indexer.Id) .BuildNew(); Db.Insert(status); @@ -42,13 +42,13 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers GivenIndexer(); var status = Builder.CreateNew() - .With(h => h.IndexerId = _indexer.Id) + .With(h => h.ProviderId = _indexer.Id) .BuildNew(); Db.Insert(status); Subject.Clean(); AllStoredModels.Should().HaveCount(1); - AllStoredModels.Should().Contain(h => h.IndexerId == _indexer.Id); + AllStoredModels.Should().Contain(h => h.ProviderId == _indexer.Id); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs index d48c06f6c..7b5b4fc1a 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs @@ -20,7 +20,7 @@ namespace NzbDrone.Core.Test.IndexerTests.IPTorrentsTests Subject.Definition = new IndexerDefinition() { Name = "IPTorrents", - Settings = new IPTorrentsSettings() { Url = "http://fake.com/" } + Settings = new IPTorrentsSettings() { BaseUrl = "http://fake.com/" } }; } diff --git a/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs index d7bee11f2..646212116 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs @@ -11,7 +11,7 @@ namespace NzbDrone.Core.Test.IndexerTests public class IndexerStatusServiceFixture : CoreTest { private DateTime _epoch; - + [SetUp] public void SetUp() { @@ -21,7 +21,7 @@ namespace NzbDrone.Core.Test.IndexerTests private void WithStatus(IndexerStatus status) { Mocker.GetMock() - .Setup(v => v.FindByIndexerId(1)) + .Setup(v => v.FindByProviderId(1)) .Returns(status); Mocker.GetMock() @@ -29,25 +29,16 @@ namespace NzbDrone.Core.Test.IndexerTests .Returns(new[] { status }); } - private void VerifyUpdate(bool updated = true) + private void VerifyUpdate() { Mocker.GetMock() - .Verify(v => v.Upsert(It.IsAny()), Times.Exactly(updated ? 1 : 0)); + .Verify(v => v.Upsert(It.IsAny()), Times.Once()); } - [Test] - public void should_start_backoff_on_first_failure() + private void VerifyNoUpdate() { - WithStatus(new IndexerStatus()); - - Subject.RecordFailure(1); - - VerifyUpdate(); - - var status = Subject.GetBlockedIndexers().FirstOrDefault(); - status.Should().NotBeNull(); - status.DisabledTill.Should().HaveValue(); - status.DisabledTill.Value.Should().BeCloseTo(_epoch + TimeSpan.FromMinutes(5), 500); + Mocker.GetMock() + .Verify(v => v.Upsert(It.IsAny()), Times.Never()); } [Test] @@ -59,7 +50,7 @@ namespace NzbDrone.Core.Test.IndexerTests VerifyUpdate(); - var status = Subject.GetBlockedIndexers().FirstOrDefault(); + var status = Subject.GetBlockedProviders().FirstOrDefault(); status.Should().BeNull(); } @@ -70,22 +61,7 @@ namespace NzbDrone.Core.Test.IndexerTests Subject.RecordSuccess(1); - VerifyUpdate(false); - } - - [Test] - public void should_preserve_escalation_on_intermittent_success() - { - WithStatus(new IndexerStatus { MostRecentFailure = _epoch - TimeSpan.FromSeconds(4), EscalationLevel = 3 }); - - Subject.RecordSuccess(1); - Subject.RecordSuccess(1); - Subject.RecordFailure(1); - - var status = Subject.GetBlockedIndexers().FirstOrDefault(); - status.Should().NotBeNull(); - status.DisabledTill.Should().HaveValue(); - status.DisabledTill.Value.Should().BeCloseTo(_epoch + TimeSpan.FromMinutes(15), 500); + VerifyNoUpdate(); } } } diff --git a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabCapabilitiesProviderFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabCapabilitiesProviderFixture.cs index e3a0e053c..f75ceab36 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabCapabilitiesProviderFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabCapabilitiesProviderFixture.cs @@ -21,7 +21,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests { _settings = new NewznabSettings() { - Url = "http://indxer.local" + BaseUrl = "http://indxer.local" }; _caps = ReadAllText("Files/Indexers/Newznab/newznab_caps.xml"); diff --git a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs index d8dd4bae3..f38cdbf8a 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs @@ -1,12 +1,15 @@ using System; using System.Linq; +using System.Net; using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Http; using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers.Newznab; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.IndexerTests.NewznabTests { @@ -24,7 +27,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests Name = "Newznab", Settings = new NewznabSettings() { - Url = "http://indexer.local/", + BaseUrl = "http://indexer.local/", Categories = new int[] { 1 } } }; @@ -43,7 +46,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests Mocker.GetMock() .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); - + var releases = Subject.FetchRecent(); releases.Should().HaveCount(100); @@ -61,6 +64,35 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests releaseInfo.Size.Should().Be(1183105773); } + + [Test] + public void should_parse_recent_feed_from_newznab_animetosho() + { + var recentFeed = ReadAllText(@"Files/Indexers/Torznab/torznab_animetosho.xml"); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(1); + + releases.First().Should().BeOfType(); + var releaseInfo = releases.First() as ReleaseInfo; + + releaseInfo.Title.Should().Be("[HorribleSubs] Frame Arms Girl - 07 [720p].mkv"); + releaseInfo.DownloadProtocol.Should().Be(DownloadProtocol.Usenet); + releaseInfo.DownloadUrl.Should().Be("http://storage.localhost/nzb/123452.nzb"); + releaseInfo.InfoUrl.Should().Be("https://localhost/view/horriblesubs-frame-arms-girl-07-720p-mkv.123452"); + releaseInfo.CommentUrl.Should().Be("https://localhost/view/horriblesubs-frame-arms-girl-07-720p-mkv.123452"); + releaseInfo.Indexer.Should().Be(Subject.Definition.Name); + releaseInfo.PublishDate.Should().Be(DateTime.Parse("Mon, 15 May 2017 19:15:56 +0000").ToUniversalTime()); + releaseInfo.Size.Should().Be(473987489); + releaseInfo.TvdbId.Should().Be(0); + releaseInfo.TvRageId.Should().Be(0); + } + [Test] public void should_use_pagesize_reported_by_caps() { @@ -69,5 +101,27 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests Subject.PageSize.Should().Be(25); } + + [Test] + public void should_record_indexer_failure_if_caps_throw() + { + var request = new HttpRequest("http://my.indexer.com"); + var response = new HttpResponse(request, new HttpHeader(), new byte[0], (HttpStatusCode)429); + response.Headers["Retry-After"] = "300"; + + Mocker.GetMock() + .Setup(v => v.GetCapabilities(It.IsAny())) + .Throws(new TooManyRequestsException(request, response)); + + _caps.MaxPageSize = 30; + _caps.DefaultPageSize = 25; + + Subject.FetchRecent().Should().BeEmpty(); + + Mocker.GetMock() + .Verify(v => v.RecordFailure(It.IsAny(), TimeSpan.FromMinutes(5.0)), Times.Once()); + + ExceptionVerification.ExpectedWarns(1); + } } } diff --git a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabRequestGeneratorFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabRequestGeneratorFixture.cs index 98de0e652..c32c888e1 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabRequestGeneratorFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabRequestGeneratorFixture.cs @@ -20,10 +20,10 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests { Subject.Settings = new NewznabSettings() { - Url = "http://127.0.0.1:1234/", - Categories = new [] { 1, 2 }, - AnimeCategories = new [] { 3, 4 }, - ApiKey = "abcd", + BaseUrl = "http://127.0.0.1:1234/", + Categories = new [] { 1, 2 }, + AnimeCategories = new [] { 3, 4 }, + ApiKey = "abcd", }; _singleEpisodeSearchCriteria = new SingleEpisodeSearchCriteria @@ -84,7 +84,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests page.Url.FullUri.Should().Contain("&cat=3,4&"); } - + [Test] public void should_use_mode_search_for_anime() { diff --git a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabSettingFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabSettingFixture.cs index 4bd26817d..21da9fa64 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabSettingFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabSettingFixture.cs @@ -15,12 +15,12 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests var setting = new NewznabSettings() { ApiKey = "", - Url = url + BaseUrl = url }; setting.Validate().IsValid.Should().BeFalse(); - setting.Validate().Errors.Should().Contain(c => c.PropertyName == "ApiKey"); + setting.Validate().Errors.Should().Contain(c => c.PropertyName == nameof(NewznabSettings.ApiKey)); } @@ -32,13 +32,13 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests var setting = new NewznabSettings { ApiKey = "", - Url = url + BaseUrl = url }; setting.Validate().IsValid.Should().BeFalse(); - setting.Validate().Errors.Should().NotContain(c => c.PropertyName == "ApiKey"); - setting.Validate().Errors.Should().Contain(c => c.PropertyName == "Url"); + setting.Validate().Errors.Should().NotContain(c => c.PropertyName == nameof(NewznabSettings.ApiKey)); + setting.Validate().Errors.Should().Contain(c => c.PropertyName == nameof(NewznabSettings.BaseUrl)); } @@ -49,11 +49,11 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests var setting = new NewznabSettings() { ApiKey = "", - Url = url + BaseUrl = url }; setting.Validate().IsValid.Should().BeTrue(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/IndexerTests/TestIndexerSettings.cs b/src/NzbDrone.Core.Test/IndexerTests/TestIndexerSettings.cs index 3006c6b36..1fcff3220 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TestIndexerSettings.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TestIndexerSettings.cs @@ -1,14 +1,17 @@ using System; +using NzbDrone.Core.Indexers; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Test.IndexerTests { - public class TestIndexerSettings : IProviderConfig + public class TestIndexerSettings : IIndexerSettings { public NzbDroneValidationResult Validate() { throw new NotImplementedException(); } + + public string BaseUrl { get; set; } } } diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssIndexerFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssIndexerFixture.cs index e1ad09a5e..23d653e5a 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssIndexerFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssIndexerFixture.cs @@ -5,9 +5,11 @@ using Moq; using NUnit.Framework; using NzbDrone.Common.Http; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Exceptions; using NzbDrone.Core.Indexers.TorrentRss; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.IndexerTests.TorrentRssIndexerTests { @@ -48,7 +50,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorrentRssIndexerTests releases.Should().HaveCount(50); releases.First().Should().BeOfType(); - + var torrentInfo = (TorrentInfo)releases.First(); torrentInfo.Title.Should().Be("Conan.2015.02.05.Jeff.Bridges.720p.HDTV.X264-CROOKS"); @@ -239,7 +241,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorrentRssIndexerTests torrentInfo.Title.Should().Be("DAYS - 05 (1280x720 HEVC2 AAC).mkv"); torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); - torrentInfo.DownloadUrl.Should().Be("http://storage.animetosho.org/torrents/4b58360143d59a55cbd922397a3eaa378165f3ff/DAYS%20-%2005%20%281280x720%20HEVC2%20AAC%29.torrent"); + torrentInfo.DownloadUrl.Should().Be("http://storage.animetosho.org/torrents/4b58360143d59a55cbd922397a3eaa378165f3ff/DAYS%20-%2005%20%281280x720%20HEVC2%20AAC%29.torrent"); } [Test] @@ -258,5 +260,18 @@ namespace NzbDrone.Core.Test.IndexerTests.TorrentRssIndexerTests torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); torrentInfo.DownloadUrl.Should().Be("https://alpharatio.cc/torrents.php?action=download&authkey=private_auth_key&torrent_pass=private_torrent_pass&id=465831"); } + + [Test] + public void should_record_indexer_failure_if_unsupported_feed() + { + GivenRecentFeedResponse("TorrentRss/invalid/TorrentDay_NoPubDate.xml"); + + Subject.FetchRecent().Should().BeEmpty(); + + Mocker.GetMock() + .Verify(v => v.RecordFailure(It.IsAny(), TimeSpan.Zero), Times.Once()); + + ExceptionVerification.ExpectedErrors(1); + } } } diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssSettingsDetectorFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssSettingsDetectorFixture.cs index e0e87b178..9a3aac3ef 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssSettingsDetectorFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssSettingsDetectorFixture.cs @@ -254,13 +254,11 @@ namespace NzbDrone.Core.Test.IndexerTests.TorrentRssIndexerTests } [TestCase("BitMeTv/BitMeTv.xml")] - [TestCase("Fanzub/fanzub.xml")] [TestCase("IPTorrents/IPTorrents.xml")] - [TestCase("Newznab/newznab_nzb_su.xml")] [TestCase("Nyaa/Nyaa.xml")] - [TestCase("Omgwtfnzbs/Omgwtfnzbs.xml")] [TestCase("Torznab/torznab_hdaccess_net.xml")] [TestCase("Torznab/torznab_tpb.xml")] + [TestCase("Torznab/torznab_animetosho.xml")] public void should_detect_recent_feed(string rssXmlFile) { GivenRecentFeedResponse(rssXmlFile); @@ -287,9 +285,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorrentRssIndexerTests var ex = Assert.Throws(() => Subject.Detect(_indexerSettings)); - ex.Message.Should().Contain("Empty feed"); - - ExceptionVerification.ExpectedErrors(1); + ex.Message.Should().Contain("Rss feed must have a pubDate"); } [TestCase("Torrentleech/Torrentleech.xml")] diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs index 8701fdc9a..e2d5a9ea7 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs @@ -25,7 +25,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests Name = "Torznab", Settings = new TorznabSettings() { - Url = "http://indexer.local/", + BaseUrl = "http://indexer.local/", Categories = new int[] { 1 } } }; @@ -44,7 +44,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests Mocker.GetMock() .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); - + var releases = Subject.FetchRecent(); releases.Should().HaveCount(5); @@ -97,6 +97,37 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests releaseInfo.Peers.Should().Be(36724); } + [Test] + public void should_parse_recent_feed_from_torznab_animetosho() + { + var recentFeed = ReadAllText(@"Files/Indexers/Torznab/torznab_animetosho.xml"); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(2); + + releases.First().Should().BeOfType(); + var releaseInfo = releases.First() as TorrentInfo; + + releaseInfo.Title.Should().Be("[finFAGs]_Frame_Arms_Girl_07_(1280x720_TV_AAC)_[1262B6F7].mkv"); + releaseInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + releaseInfo.DownloadUrl.Should().Be("http://storage.localhost/torrents/123451.torrent"); + releaseInfo.InfoUrl.Should().Be("https://localhost/view/finfags-_frame_arms_girl_07_-1280x720_tv_aac-_-1262b6f7-mkv.123451"); + releaseInfo.CommentUrl.Should().Be("https://localhost/view/finfags-_frame_arms_girl_07_-1280x720_tv_aac-_-1262b6f7-mkv.123451"); + releaseInfo.Indexer.Should().Be(Subject.Definition.Name); + releaseInfo.PublishDate.Should().Be(DateTime.Parse("Wed, 17 May 2017 20:36:06 +0000").ToUniversalTime()); + releaseInfo.Size.Should().Be(316477946); + releaseInfo.TvdbId.Should().Be(0); + releaseInfo.TvRageId.Should().Be(0); + releaseInfo.InfoHash.Should().Be("2d69a861bef5a9f2cdf791b7328e37b7953205e1"); + releaseInfo.Seeders.Should().BeNull(); + releaseInfo.Peers.Should().BeNull(); + } + [Test] public void should_use_pagesize_reported_by_caps() { diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs index 0fd99b058..2acbe077b 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs @@ -1,10 +1,13 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; using FizzWare.NBuilder; +using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.Parser; @@ -13,7 +16,6 @@ using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; using NzbDrone.Test.Common; -using FluentAssertions; namespace NzbDrone.Core.Test.MediaFiles { @@ -24,12 +26,17 @@ namespace NzbDrone.Core.Test.MediaFiles private string[] _subFolders = new[] { "c:\\root\\foldername".AsOsAgnostic() }; private string[] _videoFiles = new[] { "c:\\root\\foldername\\30.rock.s01e01.ext".AsOsAgnostic() }; + private TrackedDownload _trackedDownload; + [SetUp] public void Setup() { Mocker.GetMock().Setup(c => c.GetVideoFiles(It.IsAny(), It.IsAny())) .Returns(_videoFiles); + Mocker.GetMock().Setup(c => c.FilterFiles(It.IsAny(), It.IsAny>())) + .Returns>((b,s) => s.ToList()); + Mocker.GetMock().Setup(c => c.GetDirectories(It.IsAny())) .Returns(_subFolders); @@ -39,6 +46,23 @@ namespace NzbDrone.Core.Test.MediaFiles Mocker.GetMock() .Setup(s => s.Import(It.IsAny>(), true, null, ImportMode.Auto)) .Returns(new List()); + + + var downloadItem = Builder.CreateNew() + .With(v => v.DownloadId = "sab1") + .With(v => v.Status = DownloadItemStatus.Downloading) + .Build(); + + var remoteEpisode = Builder.CreateNew() + .With(v => v.Series = new Series()) + .Build(); + + _trackedDownload = new TrackedDownload + { + DownloadItem = downloadItem, + RemoteEpisode = remoteEpisode, + State = TrackedDownloadStage.Downloading + }; } private void GivenValidSeries() @@ -48,6 +72,29 @@ namespace NzbDrone.Core.Test.MediaFiles .Returns(Builder.CreateNew().Build()); } + private void GivenSuccessfulImport() + { + var localEpisode = new LocalEpisode(); + + var imported = new List(); + imported.Add(new ImportDecision(localEpisode)); + + Mocker.GetMock() + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true)) + .Returns(imported); + + Mocker.GetMock() + .Setup(s => s.Import(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(imported.Select(i => new ImportResult(i)).ToList()) + .Callback(() => WasImportedResponse()); + } + + private void WasImportedResponse() + { + Mocker.GetMock().Setup(c => c.GetVideoFiles(It.IsAny(), It.IsAny())) + .Returns(new string[0]); + } + [Test] public void should_search_for_series_using_folder_name() { @@ -77,7 +124,7 @@ namespace NzbDrone.Core.Test.MediaFiles Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); Mocker.GetMock() - .Verify(c => c.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), + .Verify(c => c.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); VerifyNoImport(); @@ -128,7 +175,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null, true)) + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true)) .Returns(imported); Mocker.GetMock() @@ -154,7 +201,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null, true)) + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true)) .Returns(imported); Mocker.GetMock() @@ -163,11 +210,9 @@ namespace NzbDrone.Core.Test.MediaFiles Mocker.GetMock() .Setup(s => s.IsSample(It.IsAny(), - It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) - .Returns(true); + .Returns(DetectSampleResult.Sample); Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); @@ -226,7 +271,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null, true)) + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true)) .Returns(imported); Mocker.GetMock() @@ -235,11 +280,9 @@ namespace NzbDrone.Core.Test.MediaFiles Mocker.GetMock() .Setup(s => s.IsSample(It.IsAny(), - It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) - .Returns(true); + .Returns(DetectSampleResult.Sample); Mocker.GetMock() .Setup(s => s.GetFiles(It.IsAny(), SearchOption.AllDirectories)) @@ -280,7 +323,7 @@ namespace NzbDrone.Core.Test.MediaFiles Subject.ProcessPath(fileName); Mocker.GetMock() - .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.Is(v => v.AbsoluteEpisodeNumbers.First() == 9), true), Times.Once()); + .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.Is(v => v.AbsoluteEpisodeNumbers.First() == 9), true), Times.Once()); } [Test] @@ -304,7 +347,7 @@ namespace NzbDrone.Core.Test.MediaFiles var result = Subject.ProcessPath(fileName); Mocker.GetMock() - .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null, true), Times.Once()); + .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true), Times.Once()); } [Test] @@ -337,7 +380,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null, true)) + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true)) .Returns(imported); Mocker.GetMock() @@ -346,11 +389,9 @@ namespace NzbDrone.Core.Test.MediaFiles Mocker.GetMock() .Setup(s => s.IsSample(It.IsAny(), - It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) - .Returns(true); + .Returns(DetectSampleResult.Sample); Mocker.GetMock() .Setup(s => s.GetFileSize(It.IsAny())) @@ -362,6 +403,51 @@ namespace NzbDrone.Core.Test.MediaFiles .Verify(v => v.DeleteFolder(It.IsAny(), true), Times.Never()); } + [Test] + public void should_not_delete_folder_after_import() + { + GivenValidSeries(); + + GivenSuccessfulImport(); + + _trackedDownload.DownloadItem.CanMoveFiles = false; + + Subject.ProcessPath(_droneFactory, ImportMode.Auto, _trackedDownload.RemoteEpisode.Series, _trackedDownload.DownloadItem); + + Mocker.GetMock() + .Verify(v => v.DeleteFolder(It.IsAny(), true), Times.Never()); + } + + [Test] + public void should_delete_folder_if_importmode_move() + { + GivenValidSeries(); + + GivenSuccessfulImport(); + + _trackedDownload.DownloadItem.CanMoveFiles = false; + + Subject.ProcessPath(_droneFactory, ImportMode.Move, _trackedDownload.RemoteEpisode.Series, _trackedDownload.DownloadItem); + + Mocker.GetMock() + .Verify(v => v.DeleteFolder(It.IsAny(), true), Times.Once()); + } + + [Test] + public void should_not_delete_folder_if_importmode_copy() + { + GivenValidSeries(); + + GivenSuccessfulImport(); + + _trackedDownload.DownloadItem.CanMoveFiles = true; + + Subject.ProcessPath(_droneFactory, ImportMode.Copy, _trackedDownload.RemoteEpisode.Series, _trackedDownload.DownloadItem); + + Mocker.GetMock() + .Verify(v => v.DeleteFolder(It.IsAny(), true), Times.Never()); + } + private void VerifyNoImport() { Mocker.GetMock().Verify(c => c.Import(It.IsAny>(), true, null, ImportMode.Auto), diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/SampleServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/DetectSampleFixture.cs similarity index 73% rename from src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/SampleServiceFixture.cs rename to src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/DetectSampleFixture.cs index febb5c42f..e7dd0f903 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/SampleServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/DetectSampleFixture.cs @@ -10,11 +10,12 @@ using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport { [TestFixture] - public class SampleServiceFixture : CoreTest + public class DetectSampleFixture : CoreTest { private Series _series; private LocalEpisode _localEpisode; @@ -42,11 +43,6 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport }; } - private void GivenFileSize(long size) - { - _localEpisode.Size = size; - } - private void GivenRuntime(int seconds) { Mocker.GetMock() @@ -58,7 +54,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport public void should_return_false_if_season_zero() { _localEpisode.Episodes[0].SeasonNumber = 0; - ShouldBeFalse(); + ShouldBeNotSample(); } [Test] @@ -66,7 +62,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport { _localEpisode.Path = @"C:\Test\some.show.s01e01.flv"; - ShouldBeFalse(); + ShouldBeNotSample(); Mocker.GetMock().Verify(c => c.GetRunTime(It.IsAny()), Times.Never()); } @@ -76,7 +72,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport { _localEpisode.Path = @"C:\Test\some.show.s01e01.strm"; - ShouldBeFalse(); + ShouldBeNotSample(); Mocker.GetMock().Verify(c => c.GetRunTime(It.IsAny()), Times.Never()); } @@ -85,12 +81,9 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport public void should_use_runtime() { GivenRuntime(120); - GivenFileSize(1000.Megabytes()); Subject.IsSample(_localEpisode.Series, - _localEpisode.Quality, _localEpisode.Path, - _localEpisode.Size, _localEpisode.IsSpecial); Mocker.GetMock().Verify(v => v.GetRunTime(It.IsAny()), Times.Once()); @@ -101,7 +94,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport { GivenRuntime(60); - ShouldBeTrue(); + ShouldBeSample(); } [Test] @@ -109,7 +102,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport { GivenRuntime(600); - ShouldBeFalse(); + ShouldBeNotSample(); } [Test] @@ -118,29 +111,39 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport _series.Runtime = 6; GivenRuntime(299); - ShouldBeFalse(); + ShouldBeNotSample(); } [Test] - public void should_fall_back_to_file_size_if_mediainfo_dll_not_found_acceptable_size() + public void should_return_false_if_runtime_greater_than_anime_short_minimum() { - Mocker.GetMock() - .Setup(s => s.GetRunTime(It.IsAny())) - .Throws(); + _series.Runtime = 2; + GivenRuntime(60); - GivenFileSize(1000.Megabytes()); - ShouldBeFalse(); + ShouldBeNotSample(); } [Test] - public void should_fall_back_to_file_size_if_mediainfo_dll_not_found_undersize() + public void should_return_true_if_runtime_less_than_anime_short_minimum() + { + _series.Runtime = 2; + GivenRuntime(10); + + ShouldBeSample(); + } + + [Test] + public void should_return_indeterminate_if_mediainfo_result_is_null() { Mocker.GetMock() .Setup(s => s.GetRunTime(It.IsAny())) - .Throws(); + .Returns((TimeSpan?)null); - GivenFileSize(1.Megabytes()); - ShouldBeTrue(); + Subject.IsSample(_localEpisode.Series, + _localEpisode.Path, + _localEpisode.IsSpecial).Should().Be(DetectSampleResult.Indeterminate); + + ExceptionVerification.ExpectedErrors(1); } [Test] @@ -149,7 +152,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport GivenRuntime(600); _series.SeriesType = SeriesTypes.Daily; _localEpisode.Episodes[0].SeasonNumber = 0; - ShouldBeFalse(); + ShouldBeNotSample(); } [Test] @@ -158,25 +161,21 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport _series.SeriesType = SeriesTypes.Anime; _localEpisode.Episodes[0].SeasonNumber = 0; - ShouldBeFalse(); + ShouldBeNotSample(); } - private void ShouldBeTrue() + private void ShouldBeSample() { Subject.IsSample(_localEpisode.Series, - _localEpisode.Quality, - _localEpisode.Path, - _localEpisode.Size, - _localEpisode.IsSpecial).Should().BeTrue(); - } - - private void ShouldBeFalse() - { - Subject.IsSample(_localEpisode.Series, - _localEpisode.Quality, _localEpisode.Path, - _localEpisode.Size, - _localEpisode.IsSpecial).Should().BeFalse(); + _localEpisode.IsSpecial).Should().Be(DetectSampleResult.Sample); + } + + private void ShouldBeNotSample() + { + Subject.IsSample(_localEpisode.Series, + _localEpisode.Path, + _localEpisode.IsSpecial).Should().Be(DetectSampleResult.NotSample); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs index 37268834b..d69fb7725 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs @@ -14,6 +14,7 @@ using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; using NzbDrone.Test.Common; using FizzWare.NBuilder; +using NzbDrone.Core.Download; namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport { @@ -44,13 +45,13 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport _fail2 = new Mock(); _fail3 = new Mock(); - _pass1.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); - _pass2.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); - _pass3.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); + _pass1.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Accept()); + _pass2.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Accept()); + _pass3.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Accept()); - _fail1.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Reject("_fail1")); - _fail2.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Reject("_fail2")); - _fail3.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Reject("_fail3")); + _fail1.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Reject("_fail1")); + _fail2.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Reject("_fail2")); + _fail3.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Reject("_fail3")); _series = Builder.CreateNew() .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) @@ -90,16 +91,17 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport [Test] public void should_call_all_specifications() { + var downloadClientItem = Builder.CreateNew().Build(); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - Subject.GetImportDecisions(_videoFiles, new Series(), null, false); + Subject.GetImportDecisions(_videoFiles, new Series(), downloadClientItem, null, false); - _fail1.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); - _fail2.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); - _fail3.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); - _pass1.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); - _pass2.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); - _pass3.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); + _fail1.Verify(c => c.IsSatisfiedBy(_localEpisode, downloadClientItem), Times.Once()); + _fail2.Verify(c => c.IsSatisfiedBy(_localEpisode, downloadClientItem), Times.Once()); + _fail3.Verify(c => c.IsSatisfiedBy(_localEpisode, downloadClientItem), Times.Once()); + _pass1.Verify(c => c.IsSatisfiedBy(_localEpisode, downloadClientItem), Times.Once()); + _pass2.Verify(c => c.IsSatisfiedBy(_localEpisode, downloadClientItem), Times.Once()); + _pass3.Verify(c => c.IsSatisfiedBy(_localEpisode, downloadClientItem), Times.Once()); } [Test] @@ -184,7 +186,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport GivenSpecifications(_pass1, _pass2, _pass3); var expectedQuality = QualityParser.ParseQuality(_videoFiles.Single()); - var result = Subject.GetImportDecisions(_videoFiles, _series, new ParsedEpisodeInfo{Quality = new QualityModel(Quality.SDTV)}, true); + var result = Subject.GetImportDecisions(_videoFiles, _series, null, new ParsedEpisodeInfo{Quality = new QualityModel(Quality.SDTV)}, true); result.Single().LocalEpisode.Quality.Should().Be(expectedQuality); } @@ -201,7 +203,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport var expectedQuality = new QualityModel(Quality.SDTV); - var result = Subject.GetImportDecisions(_videoFiles, _series, new ParsedEpisodeInfo { Quality = expectedQuality }, true); + var result = Subject.GetImportDecisions(_videoFiles, _series, null, new ParsedEpisodeInfo { Quality = expectedQuality }, true); result.Single().LocalEpisode.Quality.Should().Be(expectedQuality); } @@ -217,7 +219,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport var expectedQuality = new QualityModel(Quality.Bluray720p); - var result = Subject.GetImportDecisions(_videoFiles, _series, new ParsedEpisodeInfo { Quality = expectedQuality }, true); + var result = Subject.GetImportDecisions(_videoFiles, _series, null, new ParsedEpisodeInfo { Quality = expectedQuality }, true); result.Single().LocalEpisode.Quality.Should().Be(expectedQuality); } @@ -264,7 +266,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport var folderInfo = Parser.Parser.ParseTitle("Series.Title.S01"); - Subject.GetImportDecisions(_videoFiles, _series, folderInfo, true); + Subject.GetImportDecisions(_videoFiles, _series, null, folderInfo, true); Mocker.GetMock() .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), null, true), Times.Exactly(3)); @@ -287,7 +289,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport var folderInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); - Subject.GetImportDecisions(_videoFiles, _series, folderInfo, true); + Subject.GetImportDecisions(_videoFiles, _series, null, folderInfo, true); Mocker.GetMock() .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), null, true), Times.Exactly(2)); @@ -309,7 +311,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport var folderInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); - Subject.GetImportDecisions(_videoFiles, _series, folderInfo, true); + Subject.GetImportDecisions(_videoFiles, _series, null, folderInfo, true); Mocker.GetMock() .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.IsAny(), true), Times.Exactly(1)); @@ -331,12 +333,20 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport GivenVideoFiles(videoFiles.ToList()); Mocker.GetMock() - .Setup(s => s.IsSample(_series, It.IsAny(), It.Is(c => c.Contains("sample")), It.IsAny(), It.IsAny())) - .Returns(true); + .Setup(s => s.IsSample(_series, It.IsAny(), It.IsAny())) + .Returns((Series s, string path, bool special) => + { + if (path.Contains("sample")) + { + return DetectSampleResult.Sample; + } + + return DetectSampleResult.NotSample; + }); var folderInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); - Subject.GetImportDecisions(_videoFiles, _series, folderInfo, true); + Subject.GetImportDecisions(_videoFiles, _series, null, folderInfo, true); Mocker.GetMock() .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.IsAny(), true), Times.Exactly(2)); @@ -358,7 +368,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport var folderInfo = Parser.Parser.ParseTitle("Series.Title.S01E01.720p.HDTV-LOL"); - Subject.GetImportDecisions(_videoFiles, _series, folderInfo, true); + Subject.GetImportDecisions(_videoFiles, _series, null, folderInfo, true); Mocker.GetMock() .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), null, true), Times.Exactly(1)); @@ -380,7 +390,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport var folderQuality = new QualityModel(Quality.Unknown); - var result = Subject.GetImportDecisions(_videoFiles, _series, new ParsedEpisodeInfo { Quality = folderQuality}, true); + var result = Subject.GetImportDecisions(_videoFiles, _series, null, new ParsedEpisodeInfo { Quality = folderQuality}, true); result.Single().LocalEpisode.Quality.Should().Be(_quality); } diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecificationFixture.cs index a6f1afca1..8ac1b657b 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecificationFixture.cs @@ -63,7 +63,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications GivenFileSize(100.Megabytes()); GivenFreeSpace(80.Megabytes()); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); ExceptionVerification.ExpectedWarns(1); } @@ -73,7 +73,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications GivenFileSize(100.Megabytes()); GivenFreeSpace(150.Megabytes()); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); ExceptionVerification.ExpectedWarns(1); } @@ -83,7 +83,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications GivenFileSize(100.Megabytes()); GivenFreeSpace(1.Gigabytes()); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -92,7 +92,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications GivenFileSize(100.Megabytes()); GivenFreeSpace(1.Gigabytes()); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); Mocker.GetMock() .Verify(v => v.GetAvailableSpace(_rootFolder), Times.Once()); @@ -104,7 +104,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications GivenFileSize(100.Megabytes()); GivenFreeSpace(null); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -116,7 +116,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications .Setup(s => s.GetAvailableSpace(It.IsAny())) .Throws(new TestException()); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); ExceptionVerification.ExpectedErrors(1); } @@ -125,7 +125,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications { _localEpisode.ExistingFile = true; - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); Mocker.GetMock() .Verify(s => s.GetAvailableSpace(It.IsAny()), Times.Never()); @@ -140,7 +140,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications .Setup(s => s.GetAvailableSpace(It.IsAny())) .Returns(freeSpace); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -150,7 +150,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications .Setup(s => s.SkipFreeSpaceCheckWhenImporting) .Returns(true); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecificationFixture.cs index d8dced788..585f9cd40 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecificationFixture.cs @@ -34,13 +34,13 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications { _localEpisode.ParsedEpisodeInfo.FullSeason = true; - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); } [Test] public void should_return_true_when_file_does_not_contain_the_full_season() { - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualityFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualityFixture.cs new file mode 100644 index 000000000..ba7d0e1d2 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualityFixture.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.History; +using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications +{ + [TestFixture] + public class GrabbedReleaseQualityFixture : CoreTest + { + private LocalEpisode _localEpisode; + private DownloadClientItem _downloadClientItem; + + [SetUp] + public void Setup() + { + _localEpisode = Builder.CreateNew() + .With(l => l.Quality = new QualityModel(Quality.Bluray720p)) + .Build(); + + _downloadClientItem = Builder.CreateNew() + .Build(); + } + + private void GivenHistory(List history) + { + Mocker.GetMock() + .Setup(s => s.FindByDownloadId(It.IsAny())) + .Returns(history); + } + + [Test] + public void should_be_accepted_when_downloadClientItem_is_null() + { + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_no_history_for_downloadId() + { + GivenHistory(new List()); + + Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_no_grabbed_history_for_downloadId() + { + var history = Builder.CreateListOfSize(1) + .All() + .With(h => h.EventType = HistoryEventType.Unknown) + .BuildList(); + + GivenHistory(history); + + Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_grabbed_history_is_for_a_season_pack() + { + var history = Builder.CreateListOfSize(1) + .All() + .With(h => h.EventType = HistoryEventType.Grabbed) + .With(h => h.Quality = _localEpisode.Quality) + .With(h => h.SourceTitle = "Series.Title.S01.720p.HDTV.x264-RlsGroup") + .BuildList(); + + GivenHistory(history); + + Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_grabbed_history_quality_is_unknown() + { + var history = Builder.CreateListOfSize(1) + .All() + .With(h => h.EventType = HistoryEventType.Grabbed) + .With(h => h.Quality = new QualityModel(Quality.Unknown)) + .BuildList(); + + GivenHistory(history); + + Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_grabbed_history_quality_matches() + { + var history = Builder.CreateListOfSize(1) + .All() + .With(h => h.EventType = HistoryEventType.Grabbed) + .With(h => h.Quality = _localEpisode.Quality) + .BuildList(); + + GivenHistory(history); + + Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_rejected_if_grabbed_history_quality_does_not_match() + { + var history = Builder.CreateListOfSize(1) + .All() + .With(h => h.EventType = HistoryEventType.Grabbed) + .With(h => h.Quality = new QualityModel(Quality.HDTV720p)) + .BuildList(); + + GivenHistory(history); + + Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeFalse(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecificationFixture.cs index 71ff631a1..9b34c62e3 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecificationFixture.cs @@ -31,7 +31,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications { _localEpisode.ExistingFile = true; - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -39,7 +39,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications { _localEpisode.Path = @"C:\Test\Unsorted\Series.Title\S01E01.mkv".AsOsAgnostic(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -47,7 +47,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications { _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01\S01E01.mkv".AsOsAgnostic(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -55,7 +55,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications { _localEpisode.ParsedEpisodeInfo.EpisodeNumbers = new[] { 1 }; _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01E01.720p.HDTV-Sonarr\S01E01.mkv".AsOsAgnostic(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -63,14 +63,14 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications { _localEpisode.ParsedEpisodeInfo.EpisodeNumbers = new[] { 1 }; _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01E01E02.720p.HDTV-Sonarr\S01E01.mkv".AsOsAgnostic(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] public void should_be_rejected_if_file_and_folder_do_not_have_same_episode() { _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01E01.720p.HDTV-Sonarr\S01E05.mkv".AsOsAgnostic(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); } [Test] @@ -78,7 +78,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications { _localEpisode.ParsedEpisodeInfo.EpisodeNumbers = new[] { 5, 6 }; _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01E01E02.720p.HDTV-Sonarr\S01E05E06.mkv".AsOsAgnostic(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotSampleSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotSampleSpecificationFixture.cs index 1f3492205..149239632 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotSampleSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotSampleSpecificationFixture.cs @@ -42,7 +42,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications public void should_return_true_for_existing_file() { _localEpisode.ExistingFile = true; - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecificationFixture.cs index ad27e402f..74a5086d1 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecificationFixture.cs @@ -48,7 +48,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications [Test] public void should_return_true_if_not_in_working_folder() { - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -59,7 +59,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications GivenInWorkingFolder(); GivenLastWriteTimeUtc(DateTime.UtcNow.AddHours(-1)); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -68,7 +68,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications GivenInWorkingFolder(); GivenLastWriteTimeUtc(DateTime.UtcNow); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); } [Test] @@ -79,7 +79,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications GivenInWorkingFolder(); GivenLastWriteTimeUtc(DateTime.UtcNow.AddDays(-5)); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/SameFileSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/SameFileSpecificationFixture.cs new file mode 100644 index 000000000..01853ee3b --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/SameFileSpecificationFixture.cs @@ -0,0 +1,96 @@ +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using Marr.Data; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications +{ + [TestFixture] + public class SameFileSpecificationFixture : CoreTest + { + private LocalEpisode _localEpisode; + + [SetUp] + public void Setup() + { + _localEpisode = Builder.CreateNew() + .With(l => l.Size = 150.Megabytes()) + .Build(); + } + + [Test] + public void should_be_accepted_if_no_existing_file() + { + _localEpisode.Episodes = Builder.CreateListOfSize(1) + .TheFirst(1) + .With(e => e.EpisodeFileId = 0) + .BuildList(); + + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_multiple_existing_files() + { + _localEpisode.Episodes = Builder.CreateListOfSize(2) + .TheFirst(1) + .With(e => e.EpisodeFileId = 1) + .With(e => e.EpisodeFile = new LazyLoaded( + new EpisodeFile + { + Size = _localEpisode.Size + })) + .TheNext(1) + .With(e => e.EpisodeFileId = 2) + .With(e => e.EpisodeFile = new LazyLoaded( + new EpisodeFile + { + Size = _localEpisode.Size + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_file_size_is_different() + { + _localEpisode.Episodes = Builder.CreateListOfSize(1) + .TheFirst(1) + .With(e => e.EpisodeFileId = 1) + .With(e => e.EpisodeFile = new LazyLoaded( + new EpisodeFile + { + Size = _localEpisode.Size + 100.Megabytes() + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_reject_if_file_size_is_the_same() + { + _localEpisode.Episodes = Builder.CreateListOfSize(1) + .TheFirst(1) + .With(e => e.EpisodeFileId = 1) + .With(e => e.EpisodeFile = new LazyLoaded( + new EpisodeFile + { + Size = _localEpisode.Size + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/UpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/UpgradeSpecificationFixture.cs index f55cdcce2..3dbb2c7a8 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/UpgradeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/UpgradeSpecificationFixture.cs @@ -45,7 +45,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications .Build() .ToList(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -58,7 +58,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications .Build() .ToList(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -75,7 +75,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications .Build() .ToList(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -92,7 +92,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications .Build() .ToList(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -109,7 +109,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications .Build() .ToList(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); } [Test] @@ -126,7 +126,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications .Build() .ToList(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); } [Test] @@ -150,7 +150,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications .Build() .ToList(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs index 6ae1ccc10..f7be82b81 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs @@ -224,9 +224,9 @@ namespace NzbDrone.Core.Test.MediaFiles } [Test] - public void should_copy_readonly_downloads() + public void should_copy_when_cannot_move_files_downloads() { - Subject.Import(new List { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "30.Rock.S01E01", IsReadOnly = true }); + Subject.Import(new List { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "30.Rock.S01E01", CanMoveFiles = false}); Mocker.GetMock() .Verify(v => v.UpgradeEpisodeFile(It.IsAny(), _approvedDecisions.First().LocalEpisode, true), Times.Once()); @@ -235,7 +235,7 @@ namespace NzbDrone.Core.Test.MediaFiles [Test] public void should_use_override_importmode() { - Subject.Import(new List { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "30.Rock.S01E01", IsReadOnly = true }, ImportMode.Move); + Subject.Import(new List { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "30.Rock.S01E01", CanMoveFiles = false }, ImportMode.Move); Mocker.GetMock() .Verify(v => v.UpgradeEpisodeFile(It.IsAny(), _approvedDecisions.First().LocalEpisode, false), Times.Once()); diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteEpisodeFileFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteEpisodeFileFixture.cs new file mode 100644 index 000000000..c2bef40ac --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteEpisodeFileFixture.cs @@ -0,0 +1,140 @@ +using System.IO; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.MediaFileDeletionService +{ + [TestFixture] + public class DeleteEpisodeFileFixture : CoreTest + { + private static readonly string RootFolder = @"C:\Test\TV"; + private Series _series; + private EpisodeFile _episodeFile; + + [SetUp] + public void Setup() + { + _series = Builder.CreateNew() + .With(s => s.Path = Path.Combine(RootFolder, "Series Title")) + .Build(); + + _episodeFile = Builder.CreateNew() + .With(f => f.RelativePath = "Series Title - S01E01") + .With(f => f.Path = Path.Combine(_series.Path, "Series Title - S01E01")) + .Build(); + + Mocker.GetMock() + .Setup(s => s.GetParentFolder(_series.Path)) + .Returns(RootFolder); + + Mocker.GetMock() + .Setup(s => s.GetParentFolder(_episodeFile.Path)) + .Returns(_series.Path); + } + + private void GivenRootFolderExists() + { + Mocker.GetMock() + .Setup(s => s.FolderExists(RootFolder)) + .Returns(true); + } + + private void GivenRootFolderHasFolders() + { + Mocker.GetMock() + .Setup(s => s.GetDirectories(RootFolder)) + .Returns(new[] { _series.Path }); + } + + private void GivenSeriesFolderExists() + { + Mocker.GetMock() + .Setup(s => s.FolderExists(_series.Path)) + .Returns(true); + } + + [Test] + public void should_throw_if_root_folder_does_not_exist() + { + Assert.Throws(() => Subject.DeleteEpisodeFile(_series, _episodeFile)); + } + + [Test] + public void should_should_throw_if_root_folder_is_empty() + { + GivenRootFolderExists(); + Assert.Throws(() => Subject.DeleteEpisodeFile(_series, _episodeFile)); + } + + [Test] + public void should_delete_from_db_if_series_folder_does_not_exist() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + + Subject.DeleteEpisodeFile(_series, _episodeFile); + + Mocker.GetMock().Verify(v => v.Delete(_episodeFile, DeleteMediaFileReason.Manual), Times.Once()); + Mocker.GetMock().Verify(v => v.DeleteFile(_episodeFile.Path, It.IsAny()), Times.Never()); + } + + [Test] + public void should_delete_from_db_if_episode_file_does_not_exist() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + GivenSeriesFolderExists(); + + Subject.DeleteEpisodeFile(_series, _episodeFile); + + Mocker.GetMock().Verify(v => v.Delete(_episodeFile, DeleteMediaFileReason.Manual), Times.Once()); + Mocker.GetMock().Verify(v => v.DeleteFile(_episodeFile.Path, It.IsAny()), Times.Never()); + } + + [Test] + public void should_delete_from_disk_and_db_if_episode_file_exists() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + GivenSeriesFolderExists(); + + Mocker.GetMock() + .Setup(s => s.FileExists(_episodeFile.Path)) + .Returns(true); + + Subject.DeleteEpisodeFile(_series, _episodeFile); + + Mocker.GetMock().Verify(v => v.DeleteFile(_episodeFile.Path, "Series Title"), Times.Once()); + Mocker.GetMock().Verify(v => v.Delete(_episodeFile, DeleteMediaFileReason.Manual), Times.Once()); + } + + [Test] + public void should_handle_error_deleting_episode_file() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + GivenSeriesFolderExists(); + + Mocker.GetMock() + .Setup(s => s.FileExists(_episodeFile.Path)) + .Returns(true); + + Mocker.GetMock() + .Setup(s => s.DeleteFile(_episodeFile.Path, "Series Title")) + .Throws(new IOException()); + + Assert.Throws(() => Subject.DeleteEpisodeFile(_series, _episodeFile)); + + ExceptionVerification.ExpectedErrors(1); + Mocker.GetMock().Verify(v => v.DeleteFile(_episodeFile.Path, "Series Title"), Times.Once()); + Mocker.GetMock().Verify(v => v.Delete(_episodeFile, DeleteMediaFileReason.Manual), Times.Never()); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/FormattedAudioChannelsFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioChannelsFixture.cs similarity index 78% rename from src/NzbDrone.Core.Test/MediaFiles/MediaInfo/FormattedAudioChannelsFixture.cs rename to src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioChannelsFixture.cs index c344c0906..4c565585f 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/FormattedAudioChannelsFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioChannelsFixture.cs @@ -1,11 +1,12 @@ using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Test.Common; -namespace NzbDrone.Core.Test.MediaFiles.MediaInfo +namespace NzbDrone.Core.Test.MediaFiles.MediaInfo.MediaInfoFormatterTests { [TestFixture] - public class FormattedAudioChannelsFixture + public class FormatAudioChannelsFixture : TestBase { [Test] public void should_subtract_one_from_AudioChannels_as_total_channels_if_LFE_in_AudioChannelPositionsText() @@ -17,7 +18,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo AudioChannelPositionsText = "Front: L C R, Side: L R, LFE" }; - mediaInfoModel.FormattedAudioChannels.Should().Be(5.1m); + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(5.1m); } [Test] @@ -30,7 +31,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo AudioChannelPositionsText = "Front: L R" }; - mediaInfoModel.FormattedAudioChannels.Should().Be(2); + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(2); } [Test] @@ -44,7 +45,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo SchemaRevision = 2 }; - mediaInfoModel.FormattedAudioChannels.Should().Be(0); + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(0); } [Test] @@ -58,7 +59,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo SchemaRevision = 3 }; - mediaInfoModel.FormattedAudioChannels.Should().Be(2); + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(2); } [Test] @@ -72,7 +73,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo SchemaRevision = 3 }; - mediaInfoModel.FormattedAudioChannels.Should().Be(2); + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(2); } [Test] @@ -86,7 +87,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo SchemaRevision = 3 }; - mediaInfoModel.FormattedAudioChannels.Should().Be(5.1m); + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(5.1m); } [Test] @@ -100,7 +101,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo SchemaRevision = 3 }; - mediaInfoModel.FormattedAudioChannels.Should().Be(7.1m); + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(7.1m); } [Test] @@ -114,7 +115,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo SchemaRevision = 3 }; - mediaInfoModel.FormattedAudioChannels.Should().Be(7.1m); + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(7.1m); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioCodecFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioCodecFixture.cs new file mode 100644 index 000000000..4fa1e6e21 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioCodecFixture.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.MediaInfo.MediaInfoFormatterTests +{ + [TestFixture] + public class FormatAudioCodecFixture : TestBase + { + private static string sceneName = "My.Series.S01E01-Sonarr"; + + [TestCase("AC-3", "AC3")] + [TestCase("E-AC-3", "EAC3")] + [TestCase("MPEG Audio", "MPEG Audio")] + [TestCase("DTS", "DTS")] + public void should_format_audio_format_legacy(string audioFormat, string expectedFormat) + { + var mediaInfoModel = new MediaInfoModel + { + AudioFormat = audioFormat + }; + + MediaInfoFormatter.FormatAudioCodec(mediaInfoModel, sceneName).Should().Be(expectedFormat); + } + + [TestCase("MPEG Audio, A_MPEG/L2, , ", "droned.s01e03.swedish.720p.hdtv.x264-prince", "MP2")] + [TestCase("Vorbis, A_VORBIS, , Xiph.Org libVorbis I 20101101 (Schaufenugget)", "DB Super HDTV", "Vorbis")] + [TestCase("PCM, 1, , ", "DW DVDRip XviD-idTV", "PCM")] // Dubbed most likely + [TestCase("TrueHD, A_TRUEHD, , ", "", "TrueHD")] + [TestCase("WMA, 161, , ", "Droned.wmv", "WMA")] + [TestCase("WMA, 162, Pro, ", "B.N.S04E18.720p.WEB-DL", "WMA")] + public void should_format_audio_format(string audioFormatPack, string sceneName, string expectedFormat) + { + var split = audioFormatPack.Split(new string[] { ", " }, System.StringSplitOptions.None); + var mediaInfoModel = new MediaInfoModel + { + AudioFormat = split[0], + AudioCodecID = split[1], + AudioProfile = split[2], + AudioCodecLibrary = split[3] + }; + + MediaInfoFormatter.FormatAudioCodec(mediaInfoModel, sceneName).Should().Be(expectedFormat); + } + + [Test] + public void should_return_MP3_for_MPEG_Audio_with_Layer_3_for_the_profile() + { + var mediaInfoModel = new MediaInfoModel + { + AudioFormat = "MPEG Audio", + AudioProfile = "Layer 3" + }; + + MediaInfoFormatter.FormatAudioCodec(mediaInfoModel, sceneName).Should().Be("MP3"); + } + + [Test] + public void should_return_AudioFormat_by_default() + { + var mediaInfoModel = new MediaInfoModel + { + AudioFormat = "Other Audio Format", + AudioCodecID = "Other Audio Codec" + }; + + MediaInfoFormatter.FormatAudioCodec(mediaInfoModel, sceneName).Should().Be(mediaInfoModel.AudioFormat); + ExceptionVerification.ExpectedWarns(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatVideoCodecFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatVideoCodecFixture.cs new file mode 100644 index 000000000..14c391550 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatVideoCodecFixture.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.MediaInfo.MediaInfoFormatterTests +{ + [TestFixture] + public class FormatVideoCodecFixture : TestBase + { + [TestCase("AVC", null, "x264")] + [TestCase("AVC", "source.title.x264.720p-Sonarr", "x264")] + [TestCase("AVC", "source.title.h264.720p-Sonarr", "h264")] + [TestCase("V_MPEGH/ISO/HEVC", null, "x265")] + [TestCase("V_MPEGH/ISO/HEVC", "source.title.x265.720p-Sonarr", "x265")] + [TestCase("V_MPEGH/ISO/HEVC", "source.title.h265.720p-Sonarr", "h265")] + [TestCase("MPEG-2 Video", null, "MPEG2")] + public void should_format_video_codec_with_source_title(string videoCodec, string sceneName, string expectedFormat) + { + var mediaInfoModel = new MediaInfoModel + { + VideoCodec = videoCodec + }; + + MediaInfoFormatter.FormatVideoCodec(mediaInfoModel, sceneName).Should().Be(expectedFormat); + } + + [TestCase("MPEG Video, 2, Main@High, ", "Droned.S01E02.1080i.HDTV.DD5.1.MPEG2-NTb", "MPEG2")] + [TestCase("MPEG Video, V_MPEG2, Main@High, ", "", "MPEG2")] + [TestCase("MPEG Video, , , ", "The.Simpsons.S13E04.INTERNAL-ANiVCD.mpg", "MPEG")] + [TestCase("VC-1, WVC1, Advanced@L4, ", "B.N.S04E18.720p.WEB-DL", "VC1")] + [TestCase("VC-1, V_MS/VFW/FOURCC / WVC1, Advanced@L3, ", "", "VC1")] + [TestCase("VC-1, WMV3, MP@LL, ", "It's Always Sunny S07E13 The Gang's RevengeHDTV.XviD-2HD.avi", "VC1")] + [TestCase("V.MPEG4/ISO/AVC, V.MPEG4/ISO/AVC, , ", "pd.2015.S03E08.720p.iP.WEBRip.AAC2.0.H264-BTW", "h264")] + [TestCase("WMV2, WMV2, , ", "Droned.wmv", "WMV")] + [TestCase("xvid, xvid, , ", "", "XviD")] + public void should_format_video_format(string videoFormatPack, string sceneName, string expectedFormat) + { + var split = videoFormatPack.Split(new string[] { ", " }, System.StringSplitOptions.None); + var mediaInfoModel = new MediaInfoModel + { + VideoFormat = split[0], + VideoCodecID = split[1], + VideoProfile = split[2], + VideoCodecLibrary = split[3] + }; + + MediaInfoFormatter.FormatVideoCodec(mediaInfoModel, sceneName).Should().Be(expectedFormat); + } + + [Test] + public void should_return_VideoFormat_by_default() + { + var mediaInfoModel = new MediaInfoModel + { + VideoFormat = "VideoCodec" + }; + + MediaInfoFormatter.FormatVideoCodec(mediaInfoModel, null).Should().Be(mediaInfoModel.VideoFormat); + ExceptionVerification.ExpectedWarns(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/UpdateMediaInfoServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/UpdateMediaInfoServiceFixture.cs index 4ea9af0f2..eae30f9c6 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/UpdateMediaInfoServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/UpdateMediaInfoServiceFixture.cs @@ -60,7 +60,33 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo .All() .With(v => v.RelativePath = "media.mkv") .TheFirst(1) - .With(v => v.MediaInfo = new MediaInfoModel { SchemaRevision = 3 }) + .With(v => v.MediaInfo = new MediaInfoModel { SchemaRevision = UpdateMediaInfoService.CURRENT_MEDIA_INFO_SCHEMA_REVISION }) + .BuildList(); + + Mocker.GetMock() + .Setup(v => v.GetFilesBySeries(1)) + .Returns(episodeFiles); + + GivenFileExists(); + GivenSuccessfulScan(); + + Subject.Handle(new SeriesScannedEvent(_series)); + + Mocker.GetMock() + .Verify(v => v.GetMediaInfo(Path.Combine(_series.Path, "media.mkv")), Times.Exactly(2)); + + Mocker.GetMock() + .Verify(v => v.Update(It.IsAny()), Times.Exactly(2)); + } + + [Test] + public void should_skip_not_yet_date_media_info() + { + var episodeFiles = Builder.CreateListOfSize(3) + .All() + .With(v => v.RelativePath = "media.mkv") + .TheFirst(1) + .With(v => v.MediaInfo = new MediaInfoModel { SchemaRevision = UpdateMediaInfoService.MINIMUM_MEDIA_INFO_SCHEMA_REVISION }) .BuildList(); Mocker.GetMock() diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs index 5ccd1e4eb..87fb140cc 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using FluentAssertions; using Moq; using NUnit.Framework; @@ -30,11 +30,9 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo { var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "H264_sample.mp4"); - Subject.GetRunTime(path).Seconds.Should().Be(10); - + Subject.GetRunTime(path).Value.Seconds.Should().Be(10); } - [Test] public void get_info() { @@ -42,18 +40,23 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo var info = Subject.GetMediaInfo(path); - + info.VideoCodec.Should().BeNull(); + info.VideoFormat.Should().Be("AVC"); + info.VideoCodecID.Should().Be("avc1"); + info.VideoProfile.Should().Be("Baseline@L2.1"); + info.VideoCodecLibrary.Should().Be(""); + info.AudioFormat.Should().Be("AAC"); + info.AudioCodecID.Should().Be("40"); + info.AudioProfile.Should().Be("LC"); + info.AudioCodecLibrary.Should().Be(""); info.AudioBitrate.Should().Be(128000); info.AudioChannels.Should().Be(2); - info.AudioFormat.Should().Be("AAC"); info.AudioLanguages.Should().Be("English"); - info.AudioProfile.Should().Be("LC"); info.Height.Should().Be(320); info.RunTime.Seconds.Should().Be(10); info.ScanType.Should().Be("Progressive"); info.Subtitles.Should().Be(""); info.VideoBitrate.Should().Be(193329); - info.VideoCodec.Should().Be("AVC"); info.VideoFps.Should().Be(24); info.Width.Should().Be(480); @@ -73,20 +76,25 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo var info = Subject.GetMediaInfo(path); + info.VideoCodec.Should().BeNull(); + info.VideoFormat.Should().Be("AVC"); + info.VideoCodecID.Should().Be("avc1"); + info.VideoProfile.Should().Be("Baseline@L2.1"); + info.VideoCodecLibrary.Should().Be(""); + info.AudioFormat.Should().Be("AAC"); + info.AudioCodecID.Should().Be("40"); + info.AudioProfile.Should().Be("LC"); + info.AudioCodecLibrary.Should().Be(""); info.AudioBitrate.Should().Be(128000); info.AudioChannels.Should().Be(2); - info.AudioFormat.Should().Be("AAC"); info.AudioLanguages.Should().Be("English"); - info.AudioProfile.Should().Be("LC"); info.Height.Should().Be(320); info.RunTime.Seconds.Should().Be(10); info.ScanType.Should().Be("Progressive"); info.Subtitles.Should().Be(""); info.VideoBitrate.Should().Be(193329); - info.VideoCodec.Should().Be("AVC"); info.VideoFps.Should().Be(24); info.Width.Should().Be(480); - } [Test] @@ -101,4 +109,4 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo stream.Close(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs index 59c97a8d0..615d6a418 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.IO; +using System.Linq; using FizzWare.NBuilder; using FluentAssertions; using Marr.Data; @@ -31,10 +32,13 @@ namespace NzbDrone.Core.Test.MediaFiles .CreateNew() .Build(); - Mocker.GetMock() .Setup(c => c.FileExists(It.IsAny())) .Returns(true); + + Mocker.GetMock() + .Setup(c => c.GetParentFolder(It.IsAny())) + .Returns(c => Path.GetDirectoryName(c)); } private void GivenSingleEpisodeWithSingleEpisodeFile() diff --git a/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs b/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs index 4a039e699..452212815 100644 --- a/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs +++ b/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs @@ -1,121 +1,211 @@ -//using System; -//using System.Collections.Generic; -//using Moq; -//using NUnit.Framework; -//using NzbDrone.Common; -//using NzbDrone.Core.Messaging.Commands; -//using NzbDrone.Core.Messaging.Commands.Tracking; -//using NzbDrone.Core.Messaging.Events; -//using NzbDrone.Test.Common; -// -//namespace NzbDrone.Core.Test.Messaging.Commands -//{ -// [TestFixture] -// public class CommandExecutorFixture : TestBase -// { -// private Mock> _executorA; -// private Mock> _executorB; -// -// [SetUp] -// public void Setup() -// { -// _executorA = new Mock>(); -// _executorB = new Mock>(); -// -// Mocker.GetMock() -// .Setup(c => c.Build(typeof(IExecute))) -// .Returns(_executorA.Object); -// -// Mocker.GetMock() -// .Setup(c => c.Build(typeof(IExecute))) -// .Returns(_executorB.Object); -// -// -// Mocker.GetMock() -// .Setup(c => c.FindExisting(It.IsAny())) -// .Returns(null); -// } -// -// [Test] -// public void should_publish_command_to_executor() -// { -// var commandA = new CommandA(); -// -// Subject.Push(commandA); -// -// _executorA.Verify(c => c.Execute(commandA), Times.Once()); -// } -// -// [Test] -// public void should_publish_command_by_with_optional_arg_using_name() -// { -// Mocker.GetMock().Setup(c => c.GetImplementations(typeof(Command))) -// .Returns(new List { typeof(CommandA), typeof(CommandB) }); -// -// Subject.Push(typeof(CommandA).FullName); -// _executorA.Verify(c => c.Execute(It.IsAny()), Times.Once()); -// } -// -// -// [Test] -// public void should_not_publish_to_incompatible_executor() -// { -// var commandA = new CommandA(); -// -// Subject.Push(commandA); -// -// _executorA.Verify(c => c.Execute(commandA), Times.Once()); -// _executorB.Verify(c => c.Execute(It.IsAny()), Times.Never()); -// } -// -// [Test] -// public void broken_executor_should_throw_the_exception() -// { -// var commandA = new CommandA(); -// -// _executorA.Setup(c => c.Execute(It.IsAny())) -// .Throws(new NotImplementedException()); -// -// Assert.Throws(() => Subject.Push(commandA)); -// } -// -// -// [Test] -// public void broken_executor_should_publish_executed_event() -// { -// var commandA = new CommandA(); -// -// _executorA.Setup(c => c.Execute(It.IsAny())) -// .Throws(new NotImplementedException()); -// -// Assert.Throws(() => Subject.Push(commandA)); -// -// VerifyEventPublished(); -// } -// -// [Test] -// public void should_publish_executed_event_on_success() -// { -// var commandA = new CommandA(); -// Subject.Push(commandA); -// -// VerifyEventPublished(); -// } -// } -// -// public class CommandA : Command -// { -// public CommandA(int id = 0) -// { -// } -// } -// -// public class CommandB : Command -// { -// -// public CommandB() -// { -// } -// } -// -//} \ No newline at end of file +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Messaging.Commands +{ + [TestFixture] + public class CommandExecutorFixture : TestBase + { + private BlockingCollection _commandQueue; + private Mock> _executorA; + private Mock> _executorB; + private bool _commandExecuted = false; + + [SetUp] + public void Setup() + { + _executorA = new Mock>(); + _executorB = new Mock>(); + + Mocker.GetMock() + .Setup(c => c.Build(typeof(IExecute))) + .Returns(_executorA.Object); + + Mocker.GetMock() + .Setup(c => c.Build(typeof(IExecute))) + .Returns(_executorB.Object); + } + + private void GivenCommandQueue() + { + _commandQueue = new BlockingCollection(new CommandQueue()); + + Mocker.GetMock() + .Setup(s => s.Queue(It.IsAny())) + .Returns(_commandQueue.GetConsumingEnumerable); + } + + private void WaitForExecution(CommandModel commandModel) + { + Mocker.GetMock() + .Setup(s => s.Complete(It.Is(c => c == commandModel), It.IsAny())) + .Callback(() => _commandExecuted = true); + + Mocker.GetMock() + .Setup(s => s.Fail(It.Is(c => c == commandModel), It.IsAny(), It.IsAny())) + .Callback(() => _commandExecuted = true); + + while (!_commandExecuted) + { + Thread.Sleep(100); + } + + var t1 = 1; + } + + [Test] + public void should_start_executor_threads() + { + Subject.Handle(new ApplicationStartedEvent()); + + Mocker.GetMock() + .Verify(v => v.Queue(It.IsAny()), Times.AtLeastOnce()); + } + + [Test] + public void should_execute_on_executor() + { + GivenCommandQueue(); + var commandA = new CommandA(); + var commandModel = new CommandModel + { + Body = commandA + }; + + Subject.Handle(new ApplicationStartedEvent()); + _commandQueue.Add(commandModel); + + WaitForExecution(commandModel); + + _executorA.Verify(c => c.Execute(commandA), Times.Once()); + } + + [Test] + public void should_not_execute_on_incompatible_executor() + { + GivenCommandQueue(); + var commandA = new CommandA(); + var commandModel = new CommandModel + { + Body = commandA + }; + + Subject.Handle(new ApplicationStartedEvent()); + _commandQueue.Add(commandModel); + + WaitForExecution(commandModel); + + _executorA.Verify(c => c.Execute(commandA), Times.Once()); + _executorB.Verify(c => c.Execute(It.IsAny()), Times.Never()); + } + + [Test] + public void broken_executor_should_publish_executed_event() + { + GivenCommandQueue(); + var commandA = new CommandA(); + var commandModel = new CommandModel + { + Body = commandA + }; + + _executorA.Setup(s => s.Execute(It.IsAny())) + .Throws(new NotImplementedException()); + + Subject.Handle(new ApplicationStartedEvent()); + _commandQueue.Add(commandModel); + + WaitForExecution(commandModel); + + VerifyEventPublished(); + ExceptionVerification.ExpectedErrors(1); + } + + [Test] + public void should_publish_executed_event_on_success() + { + GivenCommandQueue(); + var commandA = new CommandA(); + var commandModel = new CommandModel + { + Body = commandA + }; + + Subject.Handle(new ApplicationStartedEvent()); + _commandQueue.Add(commandModel); + + WaitForExecution(commandModel); + + VerifyEventPublished(); + } + + [Test] + public void should_use_completion_message() + { + GivenCommandQueue(); + var commandA = new CommandA(); + var commandModel = new CommandModel + { + Body = commandA + }; + + Subject.Handle(new ApplicationStartedEvent()); + _commandQueue.Add(commandModel); + + WaitForExecution(commandModel); + + Mocker.GetMock() + .Setup(s => s.Complete(It.Is(c => c == commandModel), commandA.CompletionMessage)) + .Callback(() => _commandExecuted = true); + } + + [Test] + public void should_use_last_progress_message_if_completion_message_is_null() + { + GivenCommandQueue(); + var commandA = new CommandA(); + var commandModel = new CommandModel + { + Body = commandA, + Message = "Do work" + }; + + Subject.Handle(new ApplicationStartedEvent()); + _commandQueue.Add(commandModel); + + WaitForExecution(commandModel); + + Mocker.GetMock() + .Setup(s => s.Complete(It.Is(c => c == commandModel), commandModel.Message)) + .Callback(() => _commandExecuted = true); + } + } + + public class CommandA : Command + { + public CommandA(int id = 0) + { + } + } + + public class CommandB : Command + { + + public CommandB() + { + + } + + public override string CompletionMessage => null; + } + +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index f8e7b07c9..5ca9e255f 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -159,10 +159,13 @@ + + + @@ -177,6 +180,7 @@ + @@ -234,6 +238,7 @@ + @@ -279,16 +284,21 @@ - + + + + - + + + @@ -298,6 +308,7 @@ + @@ -363,7 +374,8 @@ - + + @@ -383,6 +395,7 @@ + @@ -426,6 +439,9 @@ Always + + Always + Always diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TitleTheFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TitleTheFixture.cs new file mode 100644 index 000000000..8145ead25 --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TitleTheFixture.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests +{ + [TestFixture] + public class TitleTheFixture : CoreTest + { + private Series _series; + private Episode _episode; + private EpisodeFile _episodeFile; + private NamingConfig _namingConfig; + + [SetUp] + public void Setup() + { + _series = Builder + .CreateNew() + .With(s => s.Title = "South Park") + .Build(); + + _episode = Builder.CreateNew() + .With(e => e.Title = "City Sushi") + .With(e => e.SeasonNumber = 15) + .With(e => e.EpisodeNumber = 6) + .With(e => e.AbsoluteEpisodeNumber = 100) + .Build(); + + _episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "SonarrTest" }; + + _namingConfig = NamingConfig.Default; + _namingConfig.RenameEpisodes = true; + + Mocker.GetMock() + .Setup(c => c.GetConfig()).Returns(_namingConfig); + + Mocker.GetMock() + .Setup(v => v.Get(Moq.It.IsAny())) + .Returns(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); + } + + [TestCase("The Mist", "Mist, The")] + [TestCase("A Place to Call Home", "Place to Call Home, A")] + [TestCase("An Adventure in Space and Time", "Adventure in Space and Time, An")] + [TestCase("The Flash (2010)", "Flash, The (2010)")] + [TestCase("A League Of Their Own (AU)", "League Of Their Own, A (AU)")] + [TestCase("The Fixer (ZH) (2015)", "Fixer, The (ZH) (2015)")] + [TestCase("The Sixth Sense 2 (Thai)", "Sixth Sense 2, The (Thai)")] + [TestCase("The Amazing Race (Latin America)", "Amazing Race, The (Latin America)")] + [TestCase("The Rat Pack (A&E)", "Rat Pack, The (A&E)")] + [TestCase("The Climax: I (Almost) Got Away With It (2016)", "Climax- I (Almost) Got Away With It, The (2016)")] + //[TestCase("", "")] + public void should_get_expected_title_back(string title, string expected) + { + _series.Title = title; + _namingConfig.StandardEpisodeFormat = "{Series TitleThe}"; + + Subject.BuildFileName(new List { _episode }, _series, _episodeFile) + .Should().Be(expected); + } + + [TestCase("A")] + [TestCase("Anne")] + [TestCase("Theodore")] + [TestCase("3%")] + public void should_not_change_title(string title) + { + _series.Title = title; + _namingConfig.StandardEpisodeFormat = "{Series TitleThe}"; + + Subject.BuildFileName(new List { _episode }, _series, _episodeFile) + .Should().Be(title); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs index 2712c8dbf..5b214a0e6 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs @@ -1,6 +1,8 @@ +using System.Linq; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Parser; +using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.ParserTests @@ -47,7 +49,7 @@ namespace NzbDrone.Core.Test.ParserTests public void should_remove_accents_from_title() { const string title = "Carniv\u00E0le"; - + title.CleanSeriesTitle().Should().Be("carnivale"); } @@ -62,5 +64,13 @@ namespace NzbDrone.Core.Test.ParserTests { Parser.Parser.ParseTitle(postTitle).SeriesTitle.Should().Be(title); } + + [TestCase("Revolution.S01E02.Chained.Heat.mkv")] + [TestCase("Dexter - S01E01 - Title.avi")] + public void should_parse_quality_from_extension(string title) + { + Parser.Parser.ParseTitle(title).Quality.Quality.Should().NotBe(Quality.Unknown); + Parser.Parser.ParseTitle(title).Quality.QualitySource.Should().Be(QualitySource.Extension); + } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs index 7221038e7..fb27d7437 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs @@ -192,7 +192,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests GivenAbsoluteNumberingSeries(); _parsedEpisodeInfo.Special = true; - + Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); Mocker.GetMock() @@ -210,7 +210,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests GivenAbsoluteNumberingSeries(); Mocker.GetMock() - .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle)) + .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle, It.IsAny())) .Returns(seasonNumber); Mocker.GetMock() @@ -234,7 +234,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests GivenAbsoluteNumberingSeries(); Mocker.GetMock() - .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle)) + .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle, It.IsAny())) .Returns(seasonNumber); Mocker.GetMock() @@ -258,7 +258,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests GivenAbsoluteNumberingSeries(); Mocker.GetMock() - .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle)) + .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle, It.IsAny())) .Returns(seasonNumber); Mocker.GetMock() @@ -280,7 +280,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests const int tvdbSeasonNumber = 5; Mocker.GetMock() - .Setup(s => s.FindSceneMapping(_parsedEpisodeInfo.SeriesTitle)) + .Setup(s => s.FindSceneMapping(_parsedEpisodeInfo.SeriesTitle, It.IsAny())) .Returns(new SceneMapping { SeasonNumber = tvdbSeasonNumber, SceneSeasonNumber = _parsedEpisodeInfo.SeasonNumber }); Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); @@ -298,7 +298,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests const int tvdbSeasonNumber = 5; Mocker.GetMock() - .Setup(s => s.FindSceneMapping(_parsedEpisodeInfo.SeriesTitle)) + .Setup(s => s.FindSceneMapping(_parsedEpisodeInfo.SeriesTitle, It.IsAny())) .Returns(new SceneMapping { SeasonNumber = tvdbSeasonNumber, SceneSeasonNumber = _parsedEpisodeInfo.SeasonNumber + 100 }); Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); @@ -330,7 +330,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests const int tvdbSeasonNumber = -1; Mocker.GetMock() - .Setup(s => s.FindSceneMapping(_parsedEpisodeInfo.SeriesTitle)) + .Setup(s => s.FindSceneMapping(_parsedEpisodeInfo.SeriesTitle, It.IsAny())) .Returns(new SceneMapping { SeasonNumber = tvdbSeasonNumber, SceneSeasonNumber = _parsedEpisodeInfo.SeasonNumber }); Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs index cbb616412..cce11375d 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs @@ -118,7 +118,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests GivenMatchByTvRageId(); Mocker.GetMock() - .Setup(v => v.FindTvdbId(It.IsAny())) + .Setup(v => v.FindTvdbId(It.IsAny(), It.IsAny())) .Returns(10); var result = Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId); @@ -199,7 +199,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests public void should_use_tvdbid_matching_when_alias_is_found() { Mocker.GetMock() - .Setup(s => s.FindTvdbId(It.IsAny())) + .Setup(s => s.FindTvdbId(It.IsAny(), It.IsAny())) .Returns(_series.TvdbId); Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); diff --git a/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs index 18fd75856..4299d3053 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs @@ -57,6 +57,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Lost.S04E04.720p.BluRay.x264-xHD-1", "xHD")] [TestCase("Blue.Bloods.S05E11.720p.HDTV.X264-DIMENSION-1", "DIMENSION")] [TestCase("saturday.night.live.s40e11.kevin.hart_sia.720p.hdtv.x264-w4f-sample.mkv", "w4f")] + [TestCase("The.Sequel.2017.S05E02.1080p.WEB-DL.DD5.1.H264-EVL-Scrambled", "EVL")] public void should_not_include_repost_in_release_group(string title, string expected) { Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); diff --git a/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs index 05cdaa6eb..58d61da30 100644 --- a/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs @@ -127,6 +127,8 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("this.is.not.happening.2015.0308-yestv", "this is not happening 2015", 3, 8)] [TestCase("Jeopardy - S2016E231", "Jeopardy", 2016, 231)] [TestCase("Jeopardy - 2016x231", "Jeopardy", 2016, 231)] + [TestCase("Shortland.Street.S26E022.HDTV.x264-FiHTV", "Shortland Street", 26, 22)] + [TestCase("Super.Potatoes.S01.Ep06.1080p.BluRay.DTS.x264-MiR", "Super Potatoes", 1, 6)] //[TestCase("", "", 0, 0)] public void should_parse_single_episode(string postTitle, string title, int seasonNumber, int episodeNumber) { diff --git a/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetVideoFilesFixture.cs b/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetVideoFilesFixture.cs index 397314def..0800f46fb 100644 --- a/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetVideoFilesFixture.cs +++ b/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetVideoFilesFixture.cs @@ -92,6 +92,7 @@ namespace NzbDrone.Core.Test.ProviderTests.DiskScanProviderTests [TestCase("Plex Versions")] [TestCase(".secret")] [TestCase(".hidden")] + [TestCase(".unwanted")] public void should_filter_certain_sub_folders(string subFolder) { var path = @"C:\Test\"; @@ -99,11 +100,7 @@ namespace NzbDrone.Core.Test.ProviderTests.DiskScanProviderTests var specialFiles = GetFiles(path, subFolder).ToList(); var allFiles = files.Concat(specialFiles); - var series = Builder.CreateNew() - .With(s => s.Path = path) - .Build(); - - var filteredFiles = Subject.FilterFiles(series, allFiles); + var filteredFiles = Subject.FilterFiles(path, allFiles); filteredFiles.Should().NotContain(specialFiles); filteredFiles.Count.Should().BeGreaterThan(0); } diff --git a/src/NzbDrone.Core.Test/ThingiProviderTests/NullConfigFixture.cs b/src/NzbDrone.Core.Test/ThingiProviderTests/NullConfigFixture.cs index 0f47d9a91..efc5c58bd 100644 --- a/src/NzbDrone.Core.Test/ThingiProviderTests/NullConfigFixture.cs +++ b/src/NzbDrone.Core.Test/ThingiProviderTests/NullConfigFixture.cs @@ -14,4 +14,4 @@ namespace NzbDrone.Core.Test.ThingiProviderTests Subject.Validate().IsValid.Should().BeTrue(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/ThingiProvider/ProviderBaseFixture.cs b/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderBaseFixture.cs similarity index 94% rename from src/NzbDrone.Core.Test/ThingiProvider/ProviderBaseFixture.cs rename to src/NzbDrone.Core.Test/ThingiProviderTests/ProviderBaseFixture.cs index db1e21c61..a3a5a0c51 100644 --- a/src/NzbDrone.Core.Test/ThingiProvider/ProviderBaseFixture.cs +++ b/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderBaseFixture.cs @@ -5,9 +5,8 @@ using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers.Newznab; using NzbDrone.Core.Test.Framework; -namespace NzbDrone.Core.Test.ThingiProvider +namespace NzbDrone.Core.Test.ThingiProviderTests { - public class ProviderRepositoryFixture : DbTest { [Test] @@ -27,4 +26,4 @@ namespace NzbDrone.Core.Test.ThingiProvider storedSetting.ShouldBeEquivalentTo(newznabSettings, o=>o.IncludingAllRuntimeProperties()); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderStatusServiceFixture.cs b/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderStatusServiceFixture.cs new file mode 100644 index 000000000..32a9c4b7a --- /dev/null +++ b/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderStatusServiceFixture.cs @@ -0,0 +1,126 @@ +using System; +using System.Linq; +using FluentAssertions; +using Moq; +using NLog; +using NUnit.Framework; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.Test.ThingiProviderTests +{ + public class MockProviderStatus : ProviderStatusBase + { + } + + public interface IMockProvider : IProvider + { + } + + public interface IMockProviderStatusRepository : IProviderStatusRepository + { + } + + public class MockProviderStatusService : ProviderStatusServiceBase + { + public MockProviderStatusService(IMockProviderStatusRepository providerStatusRepository, IEventAggregator eventAggregator, Logger logger) + : base(providerStatusRepository, eventAggregator, logger) + { + + } + } + + public class ProviderStatusServiceFixture : CoreTest + { + private DateTime _epoch; + + [SetUp] + public void SetUp() + { + _epoch = DateTime.UtcNow; + } + + private void WithStatus(MockProviderStatus status) + { + Mocker.GetMock() + .Setup(v => v.FindByProviderId(1)) + .Returns(status); + + Mocker.GetMock() + .Setup(v => v.All()) + .Returns(new[] { status }); + } + + private void VerifyUpdate() + { + Mocker.GetMock() + .Verify(v => v.Upsert(It.IsAny()), Times.Once()); + } + + private void VerifyNoUpdate() + { + Mocker.GetMock() + .Verify(v => v.Upsert(It.IsAny()), Times.Never()); + } + + [Test] + public void should_start_backoff_on_first_failure() + { + WithStatus(new MockProviderStatus()); + + Subject.RecordFailure(1); + + VerifyUpdate(); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().NotBeNull(); + status.DisabledTill.Should().HaveValue(); + status.DisabledTill.Value.Should().BeCloseTo(_epoch + TimeSpan.FromMinutes(5), 500); + } + + [Test] + public void should_cancel_backoff_on_success() + { + WithStatus(new MockProviderStatus { EscalationLevel = 2 }); + + Subject.RecordSuccess(1); + + VerifyUpdate(); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().BeNull(); + } + + [Test] + public void should_not_store_update_if_already_okay() + { + WithStatus(new MockProviderStatus { EscalationLevel = 0 }); + + Subject.RecordSuccess(1); + + VerifyNoUpdate(); + } + + [Test] + public void should_preserve_escalation_on_intermittent_success() + { + WithStatus(new MockProviderStatus + { + InitialFailure = _epoch - TimeSpan.FromSeconds(20), + MostRecentFailure = _epoch - TimeSpan.FromSeconds(4), + EscalationLevel = 3 + }); + + Subject.RecordSuccess(1); + Subject.RecordSuccess(1); + Subject.RecordFailure(1); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().NotBeNull(); + status.DisabledTill.Should().HaveValue(); + status.DisabledTill.Value.Should().BeCloseTo(_epoch + TimeSpan.FromMinutes(15), 500); + } + } +} diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs index 058a09b86..0cf020314 100644 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs @@ -194,6 +194,46 @@ namespace NzbDrone.Core.Test.TvTests.EpisodeMonitoredServiceTests .Verify(v => v.UpdateEpisodes(It.Is>(l => l.All(e => !e.Monitored)))); } + [Test] + public void should_should_not_monitor_episodes_if_season_is_not_monitored() + { + _series = Builder.CreateNew() + .With(s => s.Seasons = Builder.CreateListOfSize(2) + .TheFirst(1) + .With(n => n.Monitored = true) + .TheLast(1) + .With(n => n.Monitored = false) + .Build() + .ToList()) + .Build(); + + var episodes = Builder.CreateListOfSize(10) + .All() + .With(e => e.Monitored = true) + .With(e => e.EpisodeFileId = 0) + .With(e => e.AirDateUtc = DateTime.UtcNow.AddDays(-7)) + .TheFirst(5) + .With(e => e.SeasonNumber = 1) + .TheLast(5) + .With(e => e.SeasonNumber = 2) + .BuildList(); + + Mocker.GetMock() + .Setup(s => s.GetEpisodeBySeries(It.IsAny())) + .Returns(episodes); + + Subject.SetEpisodeMonitoredStatus(_series, new MonitoringOptions + { + IgnoreEpisodesWithFiles = true, + IgnoreEpisodesWithoutFiles = false + }); + + VerifyMonitored(e => e.SeasonNumber == 1); + VerifyNotMonitored(e => e.SeasonNumber == 2); + VerifySeasonMonitored(s => s.SeasonNumber == 1); + VerifySeasonNotMonitored(s => s.SeasonNumber == 2); + } + private void VerifyMonitored(Func predicate) { Mocker.GetMock() diff --git a/src/NzbDrone.Core.Test/TvTests/SeriesTitleSlugValidatorFixture.cs b/src/NzbDrone.Core.Test/TvTests/SeriesTitleSlugValidatorFixture.cs new file mode 100644 index 000000000..9cc7c435b --- /dev/null +++ b/src/NzbDrone.Core.Test/TvTests/SeriesTitleSlugValidatorFixture.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using FluentValidation.Validators; +using NUnit.Framework; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.TvTests +{ + [TestFixture] + public class SeriesTitleSlugValidatorFixture : CoreTest + { + private List _series; + private TestValidator _validator; + + [SetUp] + public void Setup() + { + _series = Builder.CreateListOfSize(1) + .Build() + .ToList(); + + _validator = new TestValidator + { + v => v.RuleFor(s => s.TitleSlug).SetValidator(Subject) + }; + + Mocker.GetMock() + .Setup(s => s.GetAllSeries()) + .Returns(_series); + } + + [Test] + public void should_not_be_valid_if_there_is_an_existing_series_with_the_same_title_slug() + { + var series = Builder.CreateNew() + .With(s => s.Id = 100) + .With(s => s.TitleSlug = _series.First().TitleSlug) + .Build(); + + _validator.Validate(series).IsValid.Should().BeFalse(); + } + + [Test] + public void should_be_valid_if_there_is_not_an_existing_series_with_the_same_title_slug() + { + var series = Builder.CreateNew() + .With(s => s.TitleSlug = "MyTitleSlug") + .Build(); + + _validator.Validate(series).IsValid.Should().BeTrue(); + } + + [Test] + public void should_be_valid_if_there_is_an_existing_series_with_a_null_title_slug() + { + _series.First().TitleSlug = null; + + var series = Builder.CreateNew() + .With(s => s.TitleSlug = "MyTitleSlug") + .Build(); + + _validator.Validate(series).IsValid.Should().BeTrue(); + } + + [Test] + public void should_be_valid_when_updating_an_existing_series() + { + _validator.Validate(_series.First().JsonClone()).IsValid.Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core/Backup/BackupService.cs b/src/NzbDrone.Core/Backup/BackupService.cs index 8cc89d87b..abe0ec00c 100644 --- a/src/NzbDrone.Core/Backup/BackupService.cs +++ b/src/NzbDrone.Core/Backup/BackupService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Data; +using System.Data.SQLite; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -25,6 +26,7 @@ namespace NzbDrone.Core.Backup public class BackupService : IBackupService, IExecute { private readonly IMainDatabase _maindDb; + private readonly IMakeDatabaseBackup _makeDatabaseBackup; private readonly IDiskTransferService _diskTransferService; private readonly IDiskProvider _diskProvider; private readonly IAppFolderInfo _appFolderInfo; @@ -36,6 +38,7 @@ namespace NzbDrone.Core.Backup private static readonly Regex BackupFileRegex = new Regex(@"nzbdrone_backup_[._0-9]+\.zip", RegexOptions.Compiled | RegexOptions.IgnoreCase); public BackupService(IMainDatabase maindDb, + IMakeDatabaseBackup makeDatabaseBackup, IDiskTransferService diskTransferService, IDiskProvider diskProvider, IAppFolderInfo appFolderInfo, @@ -43,6 +46,7 @@ namespace NzbDrone.Core.Backup Logger logger) { _maindDb = maindDb; + _makeDatabaseBackup = makeDatabaseBackup; _diskTransferService = diskTransferService; _diskProvider = diskProvider; _appFolderInfo = appFolderInfo; @@ -68,7 +72,7 @@ namespace NzbDrone.Core.Backup { CleanupOldBackups(backupType); } - + BackupConfigFile(); BackupDatabase(); @@ -111,17 +115,7 @@ namespace NzbDrone.Core.Backup { _logger.ProgressDebug("Backing up database"); - using (var unitOfWork = new UnitOfWork(() => _maindDb.GetDataMapper())) - { - unitOfWork.BeginTransaction(IsolationLevel.Serializable); - - var databaseFile = _appFolderInfo.GetNzbDroneDatabase(); - var tempDatabaseFile = Path.Combine(_backupTempFolder, Path.GetFileName(databaseFile)); - - _diskTransferService.TransferFile(databaseFile, tempDatabaseFile, TransferMode.Copy); - - unitOfWork.Commit(); - } + _makeDatabaseBackup.BackupDatabase(_maindDb, _backupTempFolder); } private void BackupConfigFile() diff --git a/src/NzbDrone.Core/Backup/MakeDatabaseBackup.cs b/src/NzbDrone.Core/Backup/MakeDatabaseBackup.cs new file mode 100644 index 000000000..bafcd8232 --- /dev/null +++ b/src/NzbDrone.Core/Backup/MakeDatabaseBackup.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Data.SQLite; +using System.IO; +using System.Linq; +using System.Text; +using NLog; +using NzbDrone.Core.Datastore; +using System.Data; + +namespace NzbDrone.Core.Backup +{ + public interface IMakeDatabaseBackup + { + void BackupDatabase(IDatabase database, string targetDirectory); + } + + public class MakeDatabaseBackup : IMakeDatabaseBackup + { + private readonly Logger _logger; + + public MakeDatabaseBackup(Logger logger) + { + _logger = logger; + } + + public void BackupDatabase(IDatabase database, string targetDirectory) + { + var sourceConnectionString = database.GetDataMapper().ConnectionString; + var backupConnectionStringBuilder = new SQLiteConnectionStringBuilder(sourceConnectionString); + + backupConnectionStringBuilder.DataSource = Path.Combine(targetDirectory, Path.GetFileName(backupConnectionStringBuilder.DataSource)); + // We MUST use journal mode instead of WAL coz WAL has issues when page sizes change. This should also automatically deal with the -journal and -wal files during restore. + backupConnectionStringBuilder.JournalMode = SQLiteJournalModeEnum.Truncate; + + using (var sourceConnection = (SQLiteConnection)SQLiteFactory.Instance.CreateConnection()) + using (var backupConnection = (SQLiteConnection)SQLiteFactory.Instance.CreateConnection()) + { + sourceConnection.ConnectionString = sourceConnectionString; + backupConnection.ConnectionString = backupConnectionStringBuilder.ToString(); + + sourceConnection.Open(); + backupConnection.Open(); + + sourceConnection.BackupDatabase(backupConnection, "main", "main", -1, null, 500); + + // The backup changes the journal_mode, force it to truncate again. + using (var command = backupConnection.CreateCommand()) + { + command.CommandText = "pragma journal_mode=truncate"; + command.ExecuteNonQuery(); + } + + // Make sure there are no lingering connections. + SQLiteConnection.ClearAllPools(); + } + } + } +} diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index 73ce4ab4e..9c2143053 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -142,7 +142,21 @@ namespace NzbDrone.Core.Configuration public bool LaunchBrowser => GetValueBoolean("LaunchBrowser", true); - public string ApiKey => GetValue("ApiKey", GenerateApiKey()); + public string ApiKey + { + get + { + var apiKey = GetValue("ApiKey", GenerateApiKey()); + + if (apiKey.IsNullOrWhiteSpace()) + { + apiKey = GenerateApiKey(); + SetValue("ApiKey", apiKey); + } + + return apiKey; + } + } public AuthenticationType AuthenticationMethod { diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/InvalidSceneMappingException.cs b/src/NzbDrone.Core/DataAugmentation/Scene/InvalidSceneMappingException.cs new file mode 100644 index 000000000..9e291eeb6 --- /dev/null +++ b/src/NzbDrone.Core/DataAugmentation/Scene/InvalidSceneMappingException.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.DataAugmentation.Scene +{ + public class InvalidSceneMappingException : NzbDroneException + { + public InvalidSceneMappingException(IEnumerable mappings, string releaseTitle) + : base(FormatMessage(mappings, releaseTitle)) + { + + } + + private static string FormatMessage(IEnumerable mappings, string releaseTitle) + { + return string.Format("Scene Mappings contains a conflict for tvdbids {0}. Please notify Sonarr developers. ({1})", string.Join(",", mappings.Select(v => v.TvdbId.ToString())), releaseTitle); + } + } +} diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs index b992aa029..d45c92d7d 100644 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs +++ b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs @@ -17,6 +17,9 @@ namespace NzbDrone.Core.DataAugmentation.Scene public int? SeasonNumber { get; set; } public int? SceneSeasonNumber { get; set; } + + public string FilterRegex { get; set; } + public string Type { get; set; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs index 44385a88f..f1adebf26 100644 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs +++ b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using NLog; using NzbDrone.Common.Cache; @@ -8,17 +8,18 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; using System.Collections.Generic; using NzbDrone.Core.Tv.Events; +using System.Text.RegularExpressions; namespace NzbDrone.Core.DataAugmentation.Scene { public interface ISceneMappingService { List GetSceneNames(int tvdbId, List seasonNumbers, List sceneSeasonNumbers); - int? FindTvdbId(string title); + int? FindTvdbId(string sceneTitle, string releaseTitle); List FindByTvdbId(int tvdbId); - SceneMapping FindSceneMapping(string title); - int? GetSceneSeasonNumber(string title); - int? GetTvdbSeasonNumber(string title); + SceneMapping FindSceneMapping(string sceneTitle, string releaseTitle); + int? GetSceneSeasonNumber(string seriesTitle, string releaseTitle); + int? GetTvdbSeasonNumber(string seriesTitle, string releaseTitle); int? GetSceneSeasonNumber(int tvdbId, int seasonNumber); } @@ -65,14 +66,14 @@ namespace NzbDrone.Core.DataAugmentation.Scene return FilterNonEnglish(names); } - public int? FindTvdbId(string title) + public int? FindTvdbId(string seriesTitle) { - var mapping = FindMapping(title); + return FindTvdbId(seriesTitle, null); + } - if (mapping == null) - return null; - - return mapping.TvdbId; + public int? FindTvdbId(string seriesTitle, string releaseTitle) + { + return FindSceneMapping(seriesTitle, releaseTitle)?.TvdbId; } public List FindByTvdbId(int tvdbId) @@ -92,33 +93,33 @@ namespace NzbDrone.Core.DataAugmentation.Scene return mappings; } - public SceneMapping FindSceneMapping(string title) + public SceneMapping FindSceneMapping(string seriesTitle, string releaseTitle) { - return FindMapping(title); - } + var mappings = FindMappings(seriesTitle, releaseTitle); - public int? GetSceneSeasonNumber(string title) - { - var mapping = FindMapping(title); - - if (mapping == null) + if (mappings == null) { return null; } - return mapping.SceneSeasonNumber; - } + var distinctMappings = mappings.DistinctBy(v => v.TvdbId).ToList(); - public int? GetTvdbSeasonNumber(string title) - { - var mapping = FindMapping(title); - - if (mapping == null) + if (distinctMappings.Count <= 1) { - return null; + return distinctMappings.FirstOrDefault(); } - return mapping.SeasonNumber; + throw new InvalidSceneMappingException(mappings, releaseTitle); + } + + public int? GetSceneSeasonNumber(string seriesTitle, string releaseTitle) + { + return FindSceneMapping(seriesTitle, releaseTitle)?.SceneSeasonNumber; + } + + public int? GetTvdbSeasonNumber(string seriesTitle, string releaseTitle) + { + return FindSceneMapping(seriesTitle, releaseTitle)?.SeasonNumber; } public int? GetSceneSeasonNumber(int tvdbId, int seasonNumber) @@ -176,52 +177,56 @@ namespace NzbDrone.Core.DataAugmentation.Scene } else { - _logger.Warn("Received empty list of mapping. will not update."); + _logger.Warn("Received empty list of mapping. will not update"); } } catch (Exception ex) { - _logger.Error(ex, "Failed to Update Scene Mappings."); + _logger.Error(ex, "Failed to Update Scene Mappings"); } } - + RefreshCache(); _eventAggregator.PublishEvent(new SceneMappingsUpdatedEvent()); } - private SceneMapping FindMapping(string title) + private List FindMappings(string seriesTitle, string releaseTitle) { if (_getTvdbIdCache.Count == 0) { RefreshCache(); } - var candidates = _getTvdbIdCache.Find(title.CleanSeriesTitle()); + var candidates = _getTvdbIdCache.Find(seriesTitle.CleanSeriesTitle()); if (candidates == null) { return null; } - if (candidates.Count == 1) + candidates = FilterSceneMappings(candidates, releaseTitle); + + if (candidates.Count <= 1) { - return candidates.First(); + return candidates; } var exactMatch = candidates.OrderByDescending(v => v.SeasonNumber) - .FirstOrDefault(v => v.Title == title); + .Where(v => v.Title == seriesTitle) + .ToList(); - if (exactMatch != null) + if (exactMatch.Any()) { return exactMatch; } - var closestMatch = candidates.OrderBy(v => title.LevenshteinDistance(v.Title, 10, 1, 10)) + var closestMatch = candidates.OrderBy(v => seriesTitle.LevenshteinDistance(v.Title, 10, 1, 10)) .ThenByDescending(v => v.SeasonNumber) .First(); - return closestMatch; + + return candidates.Where(v => v.Title == closestMatch.Title).ToList(); } private void RefreshCache() @@ -232,6 +237,26 @@ namespace NzbDrone.Core.DataAugmentation.Scene _findByTvdbIdCache.Update(mappings.GroupBy(v => v.TvdbId).ToDictionary(v => v.Key.ToString(), v => v.ToList())); } + private List FilterSceneMappings(List candidates, string releaseTitle) + { + var filteredCandidates = candidates.Where(v => v.FilterRegex.IsNotNullOrWhiteSpace()).ToList(); + var normalCandidates = candidates.Except(filteredCandidates).ToList(); + + if (releaseTitle.IsNullOrWhiteSpace()) + { + return normalCandidates; + } + + filteredCandidates = filteredCandidates.Where(v => Regex.IsMatch(releaseTitle, v.FilterRegex)).ToList(); + + if (filteredCandidates.Any()) + { + return filteredCandidates; + } + + return normalCandidates; + } + private List FilterNonEnglish(List titles) { return titles.Where(title => title.All(c => c <= 255)).ToList(); diff --git a/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs b/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs index c80cd8c92..efd30cf5b 100644 --- a/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs +++ b/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs @@ -61,7 +61,15 @@ namespace NzbDrone.Core.DataAugmentation.Xem if (episode == null) { - _logger.Debug("Information hasn't been added to TheTVDB yet, skipping."); + _logger.Debug("Information hasn't been added to TheTVDB yet, skipping"); + continue; + } + + if (mapping.Scene.Absolute == 0 && + mapping.Scene.Season == 0 && + mapping.Scene.Episode == 0) + { + _logger.Debug("Mapping for {0} S{1:00}E{2:00} is invalid, skipping", series, mapping.Tvdb.Season, mapping.Tvdb.Episode); continue; } diff --git a/src/NzbDrone.Core/Datastore/Database.cs b/src/NzbDrone.Core/Datastore/Database.cs index c4e59f983..991cd9b0e 100644 --- a/src/NzbDrone.Core/Datastore/Database.cs +++ b/src/NzbDrone.Core/Datastore/Database.cs @@ -25,7 +25,6 @@ namespace NzbDrone.Core.Datastore _datamapperFactory = datamapperFactory; } - public IDataMapper GetDataMapper() { return _datamapperFactory(); @@ -54,4 +53,4 @@ namespace NzbDrone.Core.Datastore } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Datastore/Migration/097_add_release_to_pending_releases.cs b/src/NzbDrone.Core/Datastore/Migration/097_add_release_to_pending_releases.cs new file mode 100644 index 000000000..4c15b577f --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/097_add_release_to_pending_releases.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(97)] + public class add_reason_to_pending_releases : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("PendingReleases").AddColumn("Reason").AsInt32().WithDefaultValue(0); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/102_add_language_to_episodeFiles_history_and_blacklist.cs b/src/NzbDrone.Core/Datastore/Migration/102_add_language_to_episodeFiles_history_and_blacklist.cs new file mode 100644 index 000000000..8070cce3a --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/102_add_language_to_episodeFiles_history_and_blacklist.cs @@ -0,0 +1 @@ +// This is a placeholder for migration 102 \ No newline at end of file diff --git a/src/NzbDrone.Core/Datastore/Migration/111_create_language_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/111_create_language_profiles.cs new file mode 100644 index 000000000..d6776a1ed --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/111_create_language_profiles.cs @@ -0,0 +1 @@ +// This is a placeholder for migration 111 \ No newline at end of file diff --git a/src/NzbDrone.Core/Datastore/Migration/112_added_regex_to_scenemapping.cs b/src/NzbDrone.Core/Datastore/Migration/112_added_regex_to_scenemapping.cs new file mode 100644 index 000000000..d95177a61 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/112_added_regex_to_scenemapping.cs @@ -0,0 +1,16 @@ +using System.Data; +using FluentMigrator; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(112)] + public class added_regex_to_scenemapping : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("SceneMappings").AddColumn("FilterRegex").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/113_consolidate_indexer_baseurl.cs b/src/NzbDrone.Core/Datastore/Migration/113_consolidate_indexer_baseurl.cs new file mode 100644 index 000000000..3c67405bd --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/113_consolidate_indexer_baseurl.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Data; +using FluentMigrator; +using Newtonsoft.Json.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(113)] + public class consolidate_indexer_baseurl : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.WithConnection(RenameUrlToBaseUrl); + } + + private void RenameUrlToBaseUrl(IDbConnection conn, IDbTransaction tran) + { + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT Id, Settings FROM Indexers WHERE ConfigContract IN ('NewznabSettings', 'TorznabSettings', 'IPTorrentsSettings', 'OmgwtfnzbsSettings')"; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var id = reader.GetInt32(0); + var settings = reader.GetString(1); + + if (settings.IsNotNullOrWhiteSpace()) + { + var jsonObject = Json.Deserialize(settings); + + if (jsonObject.Property("url") != null) + { + jsonObject.AddFirst(new JProperty("baseUrl", jsonObject["url"])); + jsonObject.Remove("url"); + settings = jsonObject.ToJson(); + + using (var updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = "UPDATE Indexers SET Settings = ? WHERE Id = ?"; + updateCmd.AddParameter(settings); + updateCmd.AddParameter(id); + updateCmd.ExecuteNonQuery(); + } + } + } + } + } + } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/114_rename_indexer_status_id.cs b/src/NzbDrone.Core/Datastore/Migration/114_rename_indexer_status_id.cs new file mode 100644 index 000000000..90accb1a6 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/114_rename_indexer_status_id.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(114)] + public class rename_indexer_status_id : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Rename.Column("IndexerId").OnTable("IndexerStatus").To("ProviderId"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/115_add_downloadclient_status.cs b/src/NzbDrone.Core/Datastore/Migration/115_add_downloadclient_status.cs new file mode 100644 index 000000000..552fd682e --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/115_add_downloadclient_status.cs @@ -0,0 +1,19 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(115)] + public class add_downloadclient_status : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("DownloadClientStatus") + .WithColumn("ProviderId").AsInt32().NotNullable().Unique() + .WithColumn("InitialFailure").AsDateTime().Nullable() + .WithColumn("MostRecentFailure").AsDateTime().Nullable() + .WithColumn("EscalationLevel").AsInt32().NotNullable() + .WithColumn("DisabledTill").AsDateTime().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/116_disable_nyaa.cs b/src/NzbDrone.Core/Datastore/Migration/116_disable_nyaa.cs new file mode 100644 index 000000000..9dcbe0cb0 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/116_disable_nyaa.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(116)] + public class disable_nyaa : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.Sql("UPDATE Indexers SET EnableRss = 0, EnableSearch = 0, Settings = Replace(Settings, 'https://nyaa.se', '') WHERE Implementation = 'Nyaa' AND Settings LIKE '%nyaa.se%';"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneSqliteProcessor.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneSqliteProcessor.cs index 79a9eca45..9d10255e0 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneSqliteProcessor.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneSqliteProcessor.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Data; using System.Linq; using FluentMigrator; @@ -7,6 +8,7 @@ using FluentMigrator.Model; using FluentMigrator.Runner; using FluentMigrator.Runner.Generators.SQLite; using FluentMigrator.Runner.Processors.SQLite; +using System.Text.RegularExpressions; namespace NzbDrone.Core.Datastore.Migration.Framework { @@ -62,6 +64,46 @@ namespace NzbDrone.Core.Datastore.Migration.Framework ProcessAlterTable(tableDefinition); } + public override void Process(RenameColumnExpression expression) + { + var tableDefinition = GetTableSchema(expression.TableName); + + var oldColumnDefinitions = tableDefinition.Columns.ToList(); + var columnDefinitions = tableDefinition.Columns.ToList(); + var columnIndex = columnDefinitions.FindIndex(c => c.Name == expression.OldName); + + if (columnIndex == -1) + { + throw new ApplicationException(string.Format("Column {0} does not exist on table {1}.", expression.OldName, expression.TableName)); + } + + if (columnDefinitions.Any(c => c.Name == expression.NewName)) + { + throw new ApplicationException(string.Format("Column {0} already exists on table {1}.", expression.NewName, expression.TableName)); + } + + oldColumnDefinitions[columnIndex] = (ColumnDefinition)columnDefinitions[columnIndex].Clone(); + columnDefinitions[columnIndex].Name = expression.NewName; + + foreach (var index in tableDefinition.Indexes) + { + if (index.Name.StartsWith("IX_")) + { + index.Name = Regex.Replace(index.Name, "(?<=_)" + Regex.Escape(expression.OldName) + "(?=_|$)", Regex.Escape(expression.NewName)); + } + + foreach (var column in index.Columns) + { + if (column.Name == expression.OldName) + { + column.Name = expression.NewName; + } + } + } + + ProcessAlterTable(tableDefinition, oldColumnDefinitions); + } + protected virtual TableDefinition GetTableSchema(string tableName) { var schemaDumper = new SqliteSchemaDumper(this, Announcer); @@ -70,7 +112,7 @@ namespace NzbDrone.Core.Datastore.Migration.Framework return schema.Single(v => v.Name == tableName); } - protected virtual void ProcessAlterTable(TableDefinition tableDefinition) + protected virtual void ProcessAlterTable(TableDefinition tableDefinition, List oldColumnDefinitions = null) { var tableName = tableDefinition.Name; var tempTableName = tableName + "_temp"; @@ -83,11 +125,12 @@ namespace NzbDrone.Core.Datastore.Migration.Framework // What is the cleanest way to do this? Add function to Generator? var quoter = new SQLiteQuoter(); - var columnsToTransfer = string.Join(", ", tableDefinition.Columns.Select(c => quoter.QuoteColumnName(c.Name))); + var columnsToInsert = string.Join(", ", tableDefinition.Columns.Select(c => quoter.QuoteColumnName(c.Name))); + var columnsToFetch = string.Join(", ", (oldColumnDefinitions ?? tableDefinition.Columns).Select(c => quoter.QuoteColumnName(c.Name))); Process(new CreateTableExpression() { TableName = tempTableName, Columns = tableDefinition.Columns.ToList() }); - Process(string.Format("INSERT INTO {0} SELECT {1} FROM {2}", quoter.QuoteTableName(tempTableName), columnsToTransfer, quoter.QuoteTableName(tableName))); + Process(string.Format("INSERT INTO {0} ({1}) SELECT {2} FROM {3}", quoter.QuoteTableName(tempTableName), columnsToInsert, columnsToFetch, quoter.QuoteTableName(tableName))); Process(new DeleteTableExpression() { TableName = tableName }); diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 62f6aeb8b..38787b074 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -60,7 +60,7 @@ namespace NzbDrone.Core.Datastore .Ignore(i => i.SupportsOnDownload) .Ignore(i => i.SupportsOnUpgrade) .Ignore(i => i.SupportsOnRename); - + Mapper.Entity().RegisterDefinition("Metadata"); Mapper.Entity().RegisterDefinition("DownloadClients") @@ -80,7 +80,7 @@ namespace NzbDrone.Core.Datastore .Ignore(f => f.Path) .Relationships.AutoMapICollectionOrComplexProperties() .For("Episodes") - .LazyLoad(condition: parent => parent.Id > 0, + .LazyLoad(condition: parent => parent.Id > 0, query: (db, parent) => db.Query().Where(c => c.EpisodeFileId == parent.Id).ToList()) .HasOne(file => file.Series, file => file.SeriesId); @@ -116,6 +116,7 @@ namespace NzbDrone.Core.Datastore .Ignore(c => c.Message); Mapper.Entity().RegisterModel("IndexerStatus"); + Mapper.Entity().RegisterModel("DownloadClientStatus"); } private static void RegisterMappers() @@ -171,4 +172,4 @@ namespace NzbDrone.Core.Datastore } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs index bb1a70873..343280208 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -122,8 +122,16 @@ namespace NzbDrone.Core.DecisionEngine private DownloadDecision GetDecisionForReport(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria = null) { - var reasons = _specifications.Select(c => EvaluateSpec(c, remoteEpisode, searchCriteria)) - .Where(c => c != null); + var reasons = new Rejection[0]; + + foreach (var specifications in _specifications.GroupBy(v => v.Priority).OrderBy(v => v.Key)) + { + reasons = specifications.Select(c => EvaluateSpec(c, remoteEpisode, searchCriteria)) + .Where(c => c != null) + .ToArray(); + + if (reasons.Any()) break; + } return new DownloadDecision(remoteEpisode, reasons.ToArray()); } diff --git a/src/NzbDrone.Core/DecisionEngine/IDecisionEngineSpecification.cs b/src/NzbDrone.Core/DecisionEngine/IDecisionEngineSpecification.cs index 199984734..08ea4c012 100644 --- a/src/NzbDrone.Core/DecisionEngine/IDecisionEngineSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/IDecisionEngineSpecification.cs @@ -7,6 +7,8 @@ namespace NzbDrone.Core.DecisionEngine { RejectionType Type { get; } + SpecificationPriority Priority { get; } + Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria); } } diff --git a/src/NzbDrone.Core/DecisionEngine/SpecificationPriority.cs b/src/NzbDrone.Core/DecisionEngine/SpecificationPriority.cs new file mode 100644 index 000000000..e3eb0b9d7 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/SpecificationPriority.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.DecisionEngine +{ + public enum SpecificationPriority + { + Default = 0, + Parsing = 0, + Database = 0, + Disk = 1 + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs index 4ab566d2e..d8af6bbce 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs @@ -22,6 +22,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AnimeVersionUpgradeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AnimeVersionUpgradeSpecification.cs index c2f93f7c0..3adb65df0 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/AnimeVersionUpgradeSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AnimeVersionUpgradeSpecification.cs @@ -18,6 +18,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/BlacklistSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/BlacklistSpecification.cs index 18b216263..1b2c3d05e 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/BlacklistSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/BlacklistSpecification.cs @@ -16,10 +16,11 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Database; public RejectionType Type => RejectionType.Permanent; public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) - { + { if (_blacklistService.Blacklisted(subject.Series.Id, subject.Release)) { _logger.Debug("{0} is blacklisted, rejecting.", subject.Release.Title); diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/BlockedIndexerSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/BlockedIndexerSpecification.cs new file mode 100644 index 000000000..9f7f0bc20 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/BlockedIndexerSpecification.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class BlockedIndexerSpecification : IDecisionEngineSpecification + { + private readonly IIndexerStatusService _indexerStatusService; + private readonly Logger _logger; + + private readonly ICachedDictionary _blockedIndexerCache; + + public BlockedIndexerSpecification(IIndexerStatusService indexerStatusService, ICacheManager cacheManager, Logger logger) + { + _indexerStatusService = indexerStatusService; + _logger = logger; + + _blockedIndexerCache = cacheManager.GetCacheDictionary(GetType(), "blocked", FetchBlockedIndexer, TimeSpan.FromSeconds(15)); + } + + public SpecificationPriority Priority => SpecificationPriority.Database; + public RejectionType Type => RejectionType.Temporary; + + public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + { + var status = _blockedIndexerCache.Find(subject.Release.IndexerId.ToString()); + if (status != null) + { + return Decision.Reject($"Indexer {subject.Release.Indexer} is blocked till {status.DisabledTill} due to failures, cannot grab release."); + } + + return Decision.Accept(); + } + + private IDictionary FetchBlockedIndexer() + { + return _indexerStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId.ToString()); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs index c5d52a48a..39102b0fc 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) @@ -29,7 +30,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications } _logger.Debug("Comparing file quality with report. Existing file is {0}", file.Quality); - + if (!_qualityUpgradableSpecification.CutoffNotMet(subject.Series.Profile, file.Quality, subject.ParsedEpisodeInfo.Quality)) { _logger.Debug("Cutoff already met, rejecting."); diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/FullSeasonSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/FullSeasonSpecification.cs index 023b6be60..c6349ee37 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/FullSeasonSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/FullSeasonSpecification.cs @@ -11,14 +11,13 @@ namespace NzbDrone.Core.DecisionEngine.Specifications public class FullSeasonSpecification : IDecisionEngineSpecification { private readonly Logger _logger; - private readonly IEpisodeService _episodeService; - public FullSeasonSpecification(Logger logger, IEpisodeService episodeService) + public FullSeasonSpecification(Logger logger) { _logger = logger; - _episodeService = episodeService; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/LanguageSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/LanguageSpecification.cs index 9f7f75038..edef58757 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/LanguageSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/LanguageSpecification.cs @@ -13,12 +13,13 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) { var wantedLanguage = subject.Series.Profile.Value.Language; - + _logger.Debug("Checking if report meets language requirements. {0}", subject.ParsedEpisodeInfo.Language); if (subject.ParsedEpisodeInfo.Language != wantedLanguage) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/MinimumAgeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/MinimumAgeSpecification.cs index 449d7be76..48d2bfa98 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/MinimumAgeSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/MinimumAgeSpecification.cs @@ -1,4 +1,5 @@ -using NLog; +using System; +using NLog; using NzbDrone.Core.Configuration; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; @@ -16,6 +17,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Temporary; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) @@ -28,6 +30,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications var age = subject.Release.AgeMinutes; var minimumAge = _configService.MinimumAge; + var ageRounded = Math.Round(age, 1); if (minimumAge == 0) { @@ -36,15 +39,15 @@ namespace NzbDrone.Core.DecisionEngine.Specifications } - _logger.Debug("Checking if report meets minimum age requirements. {0}", age); + _logger.Debug("Checking if report meets minimum age requirements. {0}", ageRounded); if (age < minimumAge) { - _logger.Debug("Only {0} minutes old, minimum age is {1} minutes", age, minimumAge); - return Decision.Reject("Only {0} minutes old, minimum age is {1} minutes", age, minimumAge); + _logger.Debug("Only {0} minutes old, minimum age is {1} minutes", ageRounded, minimumAge); + return Decision.Reject("Only {0} minutes old, minimum age is {1} minutes", ageRounded, minimumAge); } - _logger.Debug("Release is {0} minutes old, greater than minimum age of {1} minutes", age, minimumAge); + _logger.Debug("Release is {0} minutes old, greater than minimum age of {1} minutes", ageRounded, minimumAge); return Decision.Accept(); } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/NotSampleSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/NotSampleSpecification.cs index 02ff7653a..091efb948 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/NotSampleSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/NotSampleSpecification.cs @@ -8,6 +8,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { private readonly Logger _logger; + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public NotSampleSpecification(Logger logger) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs index 008e58812..ba956ecbd 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs @@ -18,6 +18,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs index 7913e0e7e..d8dcd9afb 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs @@ -13,6 +13,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs index 6f3ec1bea..838d8a80d 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs @@ -21,6 +21,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RawDiskSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RawDiskSpecification.cs index 7f278cb7e..23fcaa901 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RawDiskSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RawDiskSpecification.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Linq; +using System.Text.RegularExpressions; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.IndexerSearch.Definitions; @@ -8,8 +9,13 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { public class RawDiskSpecification : IDecisionEngineSpecification { - private static readonly string[] _dvdContainerTypes = new[] { "vob", "iso" }; + private static readonly Regex[] DiscRegex = new[] + { + new Regex(@"(?:dis[ck])(?:[-_. ]\d+[-_. ])(?:(?:(?:480|720|1080|2160)[ip]|)[-_. ])?(?:Blu\-?ray)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"(?:(?:480|720|1080|2160)[ip]|)[-_. ](?:full)[-_. ](?:Blu\-?ray)", RegexOptions.Compiled | RegexOptions.IgnoreCase) + }; + private static readonly string[] _dvdContainerTypes = new[] { "vob", "iso" }; private static readonly string[] _blurayContainerTypes = new[] { "m2ts" }; private readonly Logger _logger; @@ -19,11 +25,26 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) { - if (subject.Release == null || subject.Release.Container.IsNullOrWhiteSpace()) + if (subject.Release == null) + { + return Decision.Accept(); + } + + foreach (var regex in DiscRegex) + { + if (regex.IsMatch(subject.Release.Title)) + { + _logger.Debug("Release contains raw Bluray, rejecting."); + return Decision.Reject("Raw Bluray release"); + } + } + + if (subject.Release.Container.IsNullOrWhiteSpace()) { return Decision.Accept(); } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs index 9fb8c13f5..39a286af9 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs @@ -20,6 +20,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs index 97802f871..99cf93f67 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs index 68551c66c..da6285942 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs @@ -26,6 +26,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Database; public RejectionType Type => RejectionType.Temporary; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DeletedEpisodeFileSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DeletedEpisodeFileSpecification.cs new file mode 100644 index 000000000..bf4ab6ae5 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DeletedEpisodeFileSpecification.cs @@ -0,0 +1,71 @@ +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync +{ + public class DeletedEpisodeFileSpecification : IDecisionEngineSpecification + { + private readonly IDiskProvider _diskProvider; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public DeletedEpisodeFileSpecification(IDiskProvider diskProvider, IConfigService configService, Logger logger) + { + _diskProvider = diskProvider; + _configService = configService; + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Disk; + public RejectionType Type => RejectionType.Temporary; + + public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + { + if (!_configService.AutoUnmonitorPreviouslyDownloadedEpisodes) + { + return Decision.Accept(); + } + + if (searchCriteria != null) + { + _logger.Debug("Skipping deleted episodefile check during search"); + return Decision.Accept(); + } + + var missingEpisodeFiles = subject.Episodes + .Where(v => v.EpisodeFileId != 0) + .Select(v => v.EpisodeFile.Value) + .DistinctBy(v => v.Id) + .Where(v => IsEpisodeFileMissing(subject.Series, v)) + .ToArray(); + + if (missingEpisodeFiles.Any()) + { + foreach (var missingEpisodeFile in missingEpisodeFiles) + { + _logger.Trace("Episode file {0} is missing from disk.", missingEpisodeFile.RelativePath); + } + + _logger.Debug("Files for this episode exist in the database but not on disk, will be unmonitored on next diskscan. skipping."); + return Decision.Reject("Series is not monitored"); + } + + return Decision.Accept(); + } + + private bool IsEpisodeFileMissing(Series series, EpisodeFile episodeFile) + { + var fullPath = Path.Combine(series.Path, episodeFile.RelativePath); + + return !_diskProvider.FileExists(fullPath); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs index 9aa4fabf1..8ce71848c 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs @@ -26,6 +26,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Database; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) @@ -59,7 +60,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync { if (recent) { - return Decision.Reject("Recent grab event in history already meets cutoff: {0}", mostRecent.Quality); + return Decision.Reject("Recent grab event in history already meets cutoff: {0}", mostRecent.Quality); } return Decision.Reject("CDH is disabled and grab event in history already meets cutoff: {0}", mostRecent.Quality); diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredEpisodeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredEpisodeSpecification.cs index f56f26478..ca0cb5d32 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredEpisodeSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredEpisodeSpecification.cs @@ -14,6 +14,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs index 0c6632d25..f3f5d4e8d 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs @@ -20,6 +20,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/SameEpisodesGrabSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/SameEpisodesGrabSpecification.cs index 1a8c5db5b..0ef769d01 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/SameEpisodesGrabSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/SameEpisodesGrabSpecification.cs @@ -15,6 +15,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/DailyEpisodeMatchSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/DailyEpisodeMatchSpecification.cs index 50fd9b3cc..49136428f 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/DailyEpisodeMatchSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/DailyEpisodeMatchSpecification.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search _episodeService = episodeService; } + public SpecificationPriority Priority => SpecificationPriority.Database; public RejectionType Type => RejectionType.Permanent; public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) @@ -40,4 +41,4 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search return Decision.Accept(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/EpisodeRequestedSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/EpisodeRequestedSpecification.cs index 60640442f..e4bd7b2c6 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/EpisodeRequestedSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/EpisodeRequestedSpecification.cs @@ -15,6 +15,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeasonMatchSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeasonMatchSpecification.cs index b09d888ec..8516b08e7 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeasonMatchSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeasonMatchSpecification.cs @@ -13,6 +13,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) @@ -34,4 +35,4 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search return Decision.Accept(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeriesSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeriesSpecification.cs index 7f1201b33..07afbaada 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeriesSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeriesSpecification.cs @@ -13,6 +13,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) @@ -33,4 +34,4 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search return Decision.Accept(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeSearchMatchSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeSearchMatchSpecification.cs index fb056734f..4d4f76d05 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeSearchMatchSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeSearchMatchSpecification.cs @@ -14,6 +14,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) @@ -47,4 +48,4 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search return Decision.Accept(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/TorrentSeedingSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/TorrentSeedingSpecification.cs deleted file mode 100644 index 87c244b53..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/TorrentSeedingSpecification.cs +++ /dev/null @@ -1,37 +0,0 @@ -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.DecisionEngine.Specifications.Search -{ - public class TorrentSeedingSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - - public TorrentSeedingSpecification(Logger logger) - { - _logger = logger; - } - - public RejectionType Type => RejectionType.Permanent; - - - public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) - { - var torrentInfo = remoteEpisode.Release as TorrentInfo; - - if (torrentInfo == null) - { - return Decision.Accept(); - } - - if (torrentInfo.Seeders != null && torrentInfo.Seeders < 1) - { - _logger.Debug("Not enough seeders. ({0})", torrentInfo.Seeders); - return Decision.Reject("Not enough seeders. ({0})", torrentInfo.Seeders); - } - - return Decision.Accept(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/TorrentSeedingSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/TorrentSeedingSpecification.cs new file mode 100644 index 000000000..227123f96 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/TorrentSeedingSpecification.cs @@ -0,0 +1,60 @@ +using NLog; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications.Search +{ + public class TorrentSeedingSpecification : IDecisionEngineSpecification + { + private readonly IIndexerFactory _indexerFactory; + private readonly Logger _logger; + + public TorrentSeedingSpecification(IIndexerFactory indexerFactory, Logger logger) + { + _indexerFactory = indexerFactory; + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + + public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) + { + var torrentInfo = remoteEpisode.Release as TorrentInfo; + + if (torrentInfo == null || torrentInfo.IndexerId == 0) + { + return Decision.Accept(); + } + + IndexerDefinition indexer; + try + { + indexer = _indexerFactory.Get(torrentInfo.IndexerId); + } + catch (ModelNotFoundException) + { + _logger.Debug("Indexer with id {0} does not exist, skipping seeders check", torrentInfo.IndexerId); + return Decision.Accept(); + } + + var torrentIndexerSettings = indexer.Settings as ITorrentIndexerSettings; + + if (torrentIndexerSettings != null) + { + var minimumSeeders = torrentIndexerSettings.MinimumSeeders; + + if (torrentInfo.Seeders.HasValue && torrentInfo.Seeders.Value < minimumSeeders) + { + _logger.Debug("Not enough seeders: {0}. Minimum seeders: {1}", torrentInfo.Seeders, minimumSeeders); + return Decision.Reject("Not enough seeders: {0}. Minimum seeders: {1}", torrentInfo.Seeders, minimumSeeders); + } + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs index 427a4cd4f..8554c0c40 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/ScanWatchFolder.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/ScanWatchFolder.cs index d6e80e3fd..3a8c86a70 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/ScanWatchFolder.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/ScanWatchFolder.cs @@ -1,15 +1,15 @@ -using NLog; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Crypto; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Organizer; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; namespace NzbDrone.Core.Download.Clients.Blackhole { @@ -37,7 +37,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole { var newWatchItems = new Dictionary(); var lastWatchItems = _watchFolderItemCache.Get(watchFolder, () => newWatchItems); - + foreach (var newWatchItem in GetDownloadItems(watchFolder, lastWatchItems, waitPeriod)) { newWatchItems[newWatchItem.DownloadId] = newWatchItem; @@ -50,7 +50,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole private IEnumerable GetDownloadItems(string watchFolder, Dictionary lastWatchItems, TimeSpan waitPeriod) { - foreach (var folder in _diskProvider.GetDirectories(watchFolder)) + foreach (var folder in _diskScanService.FilterFiles(watchFolder, _diskProvider.GetDirectories(watchFolder))) { var title = FileNameBuilder.CleanFileName(Path.GetFileName(folder)); @@ -86,7 +86,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole yield return newWatchItem; } - foreach (var videoFile in _diskScanService.GetVideoFiles(watchFolder, false)) + foreach (var videoFile in _diskScanService.FilterFiles(watchFolder, _diskScanService.GetVideoFiles(watchFolder, false))) { var title = FileNameBuilder.CleanFileName(Path.GetFileName(videoFile)); diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs index e95297c97..eb8034fa0 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs @@ -103,7 +103,8 @@ namespace NzbDrone.Core.Download.Clients.Blackhole Status = item.Status, - IsReadOnly = Settings.ReadOnly + CanMoveFiles = !Settings.ReadOnly, + CanBeRemoved = !Settings.ReadOnly }; } } @@ -118,9 +119,9 @@ namespace NzbDrone.Core.Download.Clients.Blackhole DeleteItemData(downloadId); } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { - return new DownloadClientStatus + return new DownloadClientInfo { IsLocalhost = true, OutputRootFolders = new List { new OsPath(Settings.WatchFolder) } diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs index 2cc13a235..2a043c3cf 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs @@ -68,7 +68,10 @@ namespace NzbDrone.Core.Download.Clients.Blackhole OutputPath = item.OutputPath, - Status = item.Status + Status = item.Status, + + CanBeRemoved = true, + CanMoveFiles = true }; } } @@ -83,9 +86,9 @@ namespace NzbDrone.Core.Download.Clients.Blackhole DeleteItemData(downloadId); } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { - return new DownloadClientStatus + return new DownloadClientInfo { IsLocalhost = true, OutputRootFolders = new List { new OsPath(Settings.WatchFolder) } diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs index 6e4d023a0..e97feb963 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs @@ -57,6 +57,11 @@ namespace NzbDrone.Core.Download.Clients.Deluge { var actualHash = _proxy.AddTorrentFromFile(filename, fileContent, Settings); + if (actualHash.IsNullOrWhiteSpace()) + { + throw new DownloadClientException("Deluge failed to add torrent " + filename); + } + if (!Settings.TvCategory.IsNullOrWhiteSpace()) { _proxy.SetLabel(actualHash, Settings.TvCategory, Settings); @@ -81,21 +86,13 @@ namespace NzbDrone.Core.Download.Clients.Deluge { IEnumerable torrents; - try + if (!Settings.TvCategory.IsNullOrWhiteSpace()) { - if (!Settings.TvCategory.IsNullOrWhiteSpace()) - { - torrents = _proxy.GetTorrentsByLabel(Settings.TvCategory, Settings); - } - else - { - torrents = _proxy.GetTorrents(Settings); - } + torrents = _proxy.GetTorrentsByLabel(Settings.TvCategory, Settings); } - catch (DownloadClientException ex) + else { - _logger.Error(ex, "Couldn't get list of torrents"); - return Enumerable.Empty(); + torrents = _proxy.GetTorrents(Settings); } var items = new List(); @@ -138,14 +135,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge } // Here we detect if Deluge is managing the torrent and whether the seed criteria has been met. This allows drone to delete the torrent as appropriate. - if (torrent.IsAutoManaged && torrent.StopAtRatio && torrent.Ratio >= torrent.StopRatio && torrent.State == DelugeTorrentStatus.Paused) - { - item.IsReadOnly = false; - } - else - { - item.IsReadOnly = true; - } + item.CanMoveFiles = item.CanBeRemoved = (torrent.IsAutoManaged && torrent.StopAtRatio && torrent.Ratio >= torrent.StopRatio && torrent.State == DelugeTorrentStatus.Paused); items.Add(item); } @@ -158,7 +148,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge _proxy.RemoveTorrent(downloadId.ToLower(), deleteData, Settings); } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { var config = _proxy.GetConfig(Settings); @@ -169,7 +159,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge destDir = new OsPath(config.GetValueOrDefault("move_completed_path") as string); } - var status = new DownloadClientStatus + var status = new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" }; @@ -178,7 +168,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge { status.OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) }; } - + return status; } @@ -198,12 +188,13 @@ namespace NzbDrone.Core.Download.Clients.Deluge } catch (DownloadClientAuthenticationException ex) { - _logger.Error(ex); + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure("Password", "Authentication failed"); } catch (WebException ex) { - _logger.Error(ex); + _logger.Error(ex, "Unble to test connection"); switch (ex.Status) { case WebExceptionStatus.ConnectFailure: @@ -227,7 +218,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Failed to test connection"); return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); } @@ -278,7 +269,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to get torrents"); return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); } diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs index 3406685db..5030b5cb3 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs @@ -231,7 +231,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge } catch (WebException ex) { - throw new DownloadClientException("Unable to connect to Deluge, please check your settings", ex); + throw new DownloadClientUnavailableException("Unable to connect to Deluge, please check your settings", ex); } } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs b/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs index 9598e04ef..0e62ec97e 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs @@ -8,19 +8,16 @@ namespace NzbDrone.Core.Download.Clients public DownloadClientException(string message, params object[] args) : base(string.Format(message, args)) { - } public DownloadClientException(string message) : base(message) { - } public DownloadClientException(string message, Exception innerException, params object[] args) : base(string.Format(message, args), innerException) { - } public DownloadClientException(string message, Exception innerException) diff --git a/src/NzbDrone.Core/Download/Clients/DownloadClientUnavailableException.cs b/src/NzbDrone.Core/Download/Clients/DownloadClientUnavailableException.cs new file mode 100644 index 000000000..1878f2adb --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadClientUnavailableException.cs @@ -0,0 +1,27 @@ +using System; + +namespace NzbDrone.Core.Download.Clients +{ + public class DownloadClientUnavailableException : DownloadClientException + { + public DownloadClientUnavailableException(string message, params object[] args) + : base(string.Format(message, args)) + { + } + + public DownloadClientUnavailableException(string message) + : base(message) + { + } + + public DownloadClientUnavailableException(string message, Exception innerException, params object[] args) + : base(string.Format(message, args), innerException) + { + } + + public DownloadClientUnavailableException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs index 2a8e4b144..a88b03549 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs @@ -72,7 +72,20 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies DownloadStationSettings settings) where T : new() { var request = requestBuilder.Build(); - var response = _httpClient.Execute(request); + HttpResponse response; + + try + { + response = _httpClient.Execute(request); + } + catch (HttpException ex) + { + throw new DownloadClientException("Unable to connect to Diskstation, please check your settings", ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Unable to connect to Diskstation, please check your settings", ex); + } _logger.Debug("Trying to {0}", operation); diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs index 911152694..79c177b3f 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs @@ -90,7 +90,8 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation RemainingTime = GetRemainingTime(torrent), Status = GetStatus(torrent), Message = GetMessage(torrent), - IsReadOnly = !IsFinished(torrent) + CanMoveFiles = IsCompleted(torrent), + CanBeRemoved = IsFinished(torrent) }; if (item.Status == DownloadItemStatus.Completed || item.Status == DownloadItemStatus.Failed) @@ -104,13 +105,13 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation return items; } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { try { var path = GetDownloadDirectory(); - return new DownloadClientStatus + return new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost", OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(path)) } @@ -199,6 +200,11 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation return torrent.Status == DownloadStationTaskStatus.Finished; } + protected bool IsCompleted(DownloadStationTask torrent) + { + return torrent.Status == DownloadStationTaskStatus.Seeding || IsFinished(torrent) || (torrent.Status == DownloadStationTaskStatus.Waiting && torrent.Size != 0 && GetRemainingSize(torrent) <= 0); + } + protected string GetMessage(DownloadStationTask torrent) { if (torrent.StatusExtra != null) @@ -314,12 +320,12 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation } catch (DownloadClientAuthenticationException ex) // User could not have permission to access to downloadstation { - _logger.Error(ex); + _logger.Error(ex, ex.Message); return new NzbDroneValidationFailure(string.Empty, ex.Message); } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Error testing Torrent Download Station"); return new NzbDroneValidationFailure(string.Empty, $"Unknown exception: {ex.Message}"); } } @@ -340,7 +346,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation } catch (WebException ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to connect to Torrent Download Station"); if (ex.Status == WebExceptionStatus.ConnectFailure) { @@ -353,7 +359,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Error testing Torrent Download Station"); return new NzbDroneValidationFailure(string.Empty, $"Unknown exception: {ex.Message}"); } } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs index ad7045cbb..b36f7c26e 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs @@ -99,7 +99,8 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation RemainingSize = taskRemainingSize, Status = GetStatus(nzb), Message = GetMessage(nzb), - IsReadOnly = !IsFinished(nzb) + CanBeRemoved = true, + CanMoveFiles = true }; if (item.Status != DownloadItemStatus.Paused) @@ -129,13 +130,13 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation return finalPath; } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { try { var path = GetDownloadDirectory(); - return new DownloadClientStatus + return new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost", OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(path)) } @@ -233,12 +234,12 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation } catch (DownloadClientAuthenticationException ex) // User could not have permission to access to downloadstation { - _logger.Error(ex); + _logger.Error(ex, ex.Message); return new NzbDroneValidationFailure(string.Empty, ex.Message); } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Error testing Usenet Download Station"); return new NzbDroneValidationFailure(string.Empty, $"Unknown exception: {ex.Message}"); } } @@ -259,7 +260,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation } catch (WebException ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to connect to Usenet Download Station"); if (ex.Status == WebExceptionStatus.ConnectFailure) { @@ -272,7 +273,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Error testing Torrent Download Station"); return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); } } @@ -291,11 +292,6 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation return null; } - protected bool IsFinished(DownloadStationTask task) - { - return task.Status == DownloadStationTaskStatus.Finished; - } - protected string GetMessage(DownloadStationTask task) { if (task.StatusExtra != null) diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs index 5727dea8b..e652cd9e9 100644 --- a/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs @@ -35,17 +35,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken public override IEnumerable GetItems() { - HadoukenTorrent[] torrents; - - try - { - torrents = _proxy.GetTorrents(Settings); - } - catch (DownloadClientException ex) - { - _logger.ErrorException(ex.Message, ex); - return Enumerable.Empty(); - } + var torrents = _proxy.GetTorrents(Settings); var items = new List(); @@ -97,14 +87,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken item.Status = DownloadItemStatus.Downloading; } - if (torrent.IsFinished && torrent.State == HadoukenTorrentState.Paused) - { - item.IsReadOnly = false; - } - else - { - item.IsReadOnly = true; - } + item.CanMoveFiles = item.CanBeRemoved = (torrent.IsFinished && torrent.State == HadoukenTorrentState.Paused); items.Add(item); } @@ -124,12 +107,12 @@ namespace NzbDrone.Core.Download.Clients.Hadouken } } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { var config = _proxy.GetConfig(Settings); var destDir = new OsPath(config.GetValueOrDefault("bittorrent.defaultSavePath") as string); - var status = new DownloadClientStatus + var status = new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" }; @@ -170,7 +153,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken if (version < new Version("5.1")) { - return new ValidationFailure(string.Empty, "Old Hadouken client with unsupported API, need 5.1 or higher"); + return new ValidationFailure(string.Empty, "Old Hadouken client with unsupported API, need 5.1 or higher"); } } catch (DownloadClientAuthenticationException ex) diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs index e044dd912..20e60f1fb 100644 --- a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs @@ -77,7 +77,21 @@ namespace NzbDrone.Core.Download.Clients.Hadouken requestBuilder.Headers.Add("Accept-Encoding", "gzip,deflate"); var httpRequest = requestBuilder.Build(); - var response = _httpClient.Execute(httpRequest); + HttpResponse response; + + try + { + response = _httpClient.Execute(httpRequest); + } + catch (HttpException ex) + { + throw new DownloadClientException("Unable to connect to Hadouken, please check your settings", ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Unable to connect to Hadouken, please check your settings", ex); + } + var result = Json.Deserialize>(response.Content); if (result.Error != null) diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs index dc3595615..528fce3b7 100644 --- a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs @@ -47,17 +47,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex public override IEnumerable GetItems() { - List vortexQueue; - - try - { - vortexQueue = _proxy.GetQueue(30, Settings); - } - catch (DownloadClientException ex) - { - _logger.Warn("Couldn't get download queue. {0}", ex.Message); - return Enumerable.Empty(); - } + var vortexQueue = _proxy.GetQueue(30, Settings); var queueItems = new List(); @@ -72,7 +62,9 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex queueItem.TotalSize = vortexQueueItem.TotalDownloadSize; queueItem.RemainingSize = vortexQueueItem.TotalDownloadSize - vortexQueueItem.DownloadedSize; queueItem.RemainingTime = null; - + queueItem.CanBeRemoved = true; + queueItem.CanMoveFiles = true; + if (vortexQueueItem.IsPaused) { queueItem.Status = DownloadItemStatus.Paused; @@ -132,7 +124,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex { _proxy.Remove(queueItem.Id, deleteData, Settings); } - } + } } protected List GetGroups() @@ -140,9 +132,9 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex return _proxy.GetGroups(Settings); } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { - var status = new DownloadClientStatus + var status = new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" }; @@ -166,7 +158,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to connect to NZBVortex"); return new ValidationFailure("Host", "Unable to connect to NZBVortex"); } @@ -187,7 +179,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to connect to NZBVortex"); return new ValidationFailure("Host", "Unable to connect to NZBVortex"); } @@ -256,4 +248,4 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex return new OsPath(Path.Combine(outputPath.FullPath, filesResponse.First().FileName)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs index 15450c280..854246bc5 100644 --- a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs @@ -164,7 +164,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex } catch (WebException ex) { - throw new DownloadClientException("Unable to connect to NZBVortex, please check your settings", ex); + throw new DownloadClientUnavailableException("Unable to connect to NZBVortex, please check your settings", ex); } } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs index 949186fc7..84c6a1dd3 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs @@ -1,7 +1,8 @@ using System; +using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; -using System.Collections.Generic; using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; @@ -9,8 +10,8 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Validation; using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Download.Clients.Nzbget { @@ -51,19 +52,8 @@ namespace NzbDrone.Core.Download.Clients.Nzbget private IEnumerable GetQueue() { - NzbgetGlobalStatus globalStatus; - List queue; - - try - { - globalStatus = _proxy.GetGlobalStatus(Settings); - queue = _proxy.GetQueue(Settings); - } - catch (DownloadClientException ex) - { - _logger.Error(ex); - return Enumerable.Empty(); - } + var globalStatus = _proxy.GetGlobalStatus(Settings); + var queue = _proxy.GetQueue(Settings); var queueItems = new List(); @@ -83,6 +73,8 @@ namespace NzbDrone.Core.Download.Clients.Nzbget queueItem.TotalSize = totalSize; queueItem.Category = item.Category; queueItem.DownloadClient = Definition.Name; + queueItem.CanMoveFiles = true; + queueItem.CanBeRemoved = true; if (globalStatus.DownloadPaused || remainingSize == pausedSize && remainingSize != 0) { @@ -117,17 +109,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget private IEnumerable GetHistory() { - List history; - - try - { - history = _proxy.GetHistory(Settings).Take(_configService.DownloadClientHistoryLimit).ToList(); - } - catch (DownloadClientException ex) - { - _logger.Error(ex); - return Enumerable.Empty(); - } + var history = _proxy.GetHistory(Settings).Take(_configService.DownloadClientHistoryLimit).ToList(); var historyItems = new List(); @@ -145,6 +127,8 @@ namespace NzbDrone.Core.Download.Clients.Nzbget historyItem.Message = $"PAR Status: {item.ParStatus} - Unpack Status: {item.UnpackStatus} - Move Status: {item.MoveStatus} - Script Status: {item.ScriptStatus} - Delete Status: {item.DeleteStatus} - Mark Status: {item.MarkStatus}"; historyItem.Status = DownloadItemStatus.Completed; historyItem.RemainingTime = TimeSpan.Zero; + historyItem.CanMoveFiles = true; + historyItem.CanBeRemoved = true; if (item.DeleteStatus == "MANUAL") { @@ -210,13 +194,13 @@ namespace NzbDrone.Core.Download.Clients.Nzbget _proxy.RemoveItem(downloadId, Settings); } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { var config = _proxy.GetConfig(Settings); var category = GetCategories(config).FirstOrDefault(v => v.Name == Settings.TvCategory); - var status = new DownloadClientStatus + var status = new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" }; @@ -285,7 +269,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget { return new ValidationFailure("Username", "Authentication failed"); } - _logger.Error(ex); + _logger.Error(ex, "Unable to connect to NZBGet"); return new ValidationFailure("Host", "Unable to connect to NZBGet"); } @@ -313,8 +297,9 @@ namespace NzbDrone.Core.Download.Clients.Nzbget { var config = _proxy.GetConfig(Settings); - var keepHistory = config.GetValueOrDefault("KeepHistory"); - if (keepHistory == "0") + var keepHistory = config.GetValueOrDefault("KeepHistory", "7"); + int value; + if (!int.TryParse(keepHistory, NumberStyles.None, CultureInfo.InvariantCulture, out value) || value == 0) { return new NzbDroneValidationFailure(string.Empty, "NzbGet setting KeepHistory should be greater than 0") { @@ -322,6 +307,14 @@ namespace NzbDrone.Core.Download.Clients.Nzbget DetailedDescription = "NzbGet setting KeepHistory is set to 0. Which prevents Sonarr from seeing completed downloads." }; } + else if (value > 25000) + { + return new NzbDroneValidationFailure(string.Empty, "NzbGet setting KeepHistory should be less than 25000") + { + InfoLink = string.Format("http://{0}:{1}/", Settings.Host, Settings.Port), + DetailedDescription = "NzbGet setting KeepHistory is set too high." + }; + } return null; } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs index 7a21b45b1..7338fdecd 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs @@ -235,14 +235,14 @@ namespace NzbDrone.Core.Download.Clients.Nzbget { if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) { - throw new DownloadClientException("Authentication failed for NzbGet, please check your settings", ex); + throw new DownloadClientAuthenticationException("Authentication failed for NzbGet, please check your settings", ex); } throw new DownloadClientException("Unable to connect to NzbGet. " + ex.Message, ex); } catch (WebException ex) { - throw new DownloadClientException("Unable to connect to NzbGet. " + ex.Message, ex); + throw new DownloadClientUnavailableException("Unable to connect to NzbGet. " + ex.Message, ex); } var result = Json.Deserialize>(response.Content); diff --git a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs index 5eab58b3b..c2ca17a63 100644 --- a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs @@ -77,6 +77,9 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic DownloadId = GetDownloadClientId(file), Title = title, + CanBeRemoved = true, + CanMoveFiles = true, + TotalSize = _diskProvider.GetFileSize(file), OutputPath = new OsPath(file) @@ -100,9 +103,9 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic throw new NotSupportedException(); } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { - var status = new DownloadClientStatus + var status = new DownloadClientInfo { IsLocalhost = true }; diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index 55eec2682..fa6546c3b 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -55,17 +55,31 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { _proxy.AddTorrentFromFile(filename, fileContent, Settings); - if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + try { - _proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); + if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + { + _proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); + } + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to set the torrent label for {0}.", filename); } - var isRecentEpisode = remoteEpisode.IsRecentEpisode(); - - if (isRecentEpisode && Settings.RecentTvPriority == (int)QBittorrentPriority.First || - !isRecentEpisode && Settings.OlderTvPriority == (int)QBittorrentPriority.First) + try { - _proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + + if (isRecentEpisode && Settings.RecentTvPriority == (int)QBittorrentPriority.First || + !isRecentEpisode && Settings.OlderTvPriority == (int)QBittorrentPriority.First) + { + _proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + } + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to set the torrent priority for {0}.", filename); } return hash; @@ -75,19 +89,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public override IEnumerable GetItems() { - QBittorrentPreferences config; - List torrents; - - try - { - config = _proxy.GetConfig(Settings); - torrents = _proxy.GetTorrents(Settings); - } - catch (DownloadClientException ex) - { - _logger.Error(ex); - return Enumerable.Empty(); - } + var config = _proxy.GetConfig(Settings); + var torrents = _proxy.GetTorrents(Settings); var queueItems = new List(); @@ -106,7 +109,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent // Avoid removing torrents that haven't reached the global max ratio. // Removal also requires the torrent to be paused, in case a higher max ratio was set on the torrent itself (which is not exposed by the api). - item.IsReadOnly = (config.MaxRatioEnabled && config.MaxRatio > torrent.Ratio) || torrent.State != "pausedUP"; + item.CanMoveFiles = item.CanBeRemoved = (!config.MaxRatioEnabled || config.MaxRatio <= torrent.Ratio) && torrent.State == "pausedUP"; if (!item.OutputPath.IsEmpty && item.OutputPath.FileName != torrent.Name) { @@ -129,7 +132,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent item.Status = DownloadItemStatus.Queued; break; - case "pausedUP": // torrent is paused and has finished downloading + case "pausedUP": // torrent is paused and has finished downloading: case "uploading": // torrent is being seeded and data is being transfered case "stalledUP": // torrent is being seeded, but no connection were made case "queuedUP": // queuing is enabled and torrent is queued for upload @@ -160,13 +163,13 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent _proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings); } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { var config = _proxy.GetConfig(Settings); var destDir = new OsPath(config.SavePath); - return new DownloadClientStatus + return new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost", OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) } @@ -177,6 +180,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { failures.AddIfNotNull(TestConnection()); if (failures.Any()) return; + failures.AddIfNotNull(TestPrioritySupport()); failures.AddIfNotNull(TestGetTorrents()); } @@ -226,7 +230,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } catch (DownloadClientAuthenticationException ex) { - _logger.Error(ex); + _logger.Error(ex, ex.Message); return new NzbDroneValidationFailure("Username", "Authentication failure") { DetailedDescription = "Please verify your username and password." @@ -234,7 +238,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } catch (WebException ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to connect to qBittorrent"); if (ex.Status == WebExceptionStatus.ConnectFailure) { return new NzbDroneValidationFailure("Host", "Unable to connect") @@ -246,7 +250,42 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to test qBittorrent"); + return new NzbDroneValidationFailure(String.Empty, "Unknown exception: " + ex.Message); + } + + return null; + } + + private ValidationFailure TestPrioritySupport() + { + var recentPriorityDefault = Settings.RecentTvPriority == (int)QBittorrentPriority.Last; + var olderPriorityDefault = Settings.OlderTvPriority == (int)QBittorrentPriority.Last; + + if (olderPriorityDefault && recentPriorityDefault) + { + return null; + } + + try + { + var config = _proxy.GetConfig(Settings); + + if (!config.QueueingEnabled) + { + if (!recentPriorityDefault) + { + return new NzbDroneValidationFailure(nameof(Settings.RecentTvPriority), "Queueing not enabled") { DetailedDescription = "Torrent Queueing is not enabled in your qBittorrent settings. Enable it in qBittorrent or select 'Last' as priority." }; + } + else if (!olderPriorityDefault) + { + return new NzbDroneValidationFailure(nameof(Settings.OlderTvPriority), "Queueing not enabled") { DetailedDescription = "Torrent Queueing is not enabled in your qBittorrent settings. Enable it in qBittorrent or select 'Last' as priority." }; + } + } + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to test qBittorrent"); return new NzbDroneValidationFailure(String.Empty, "Unknown exception: " + ex.Message); } @@ -261,7 +300,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Failed to get torrents"); return new NzbDroneValidationFailure(String.Empty, "Failed to get the list of torrents: " + ex.Message); } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs index 9fddb1116..3278cbaab 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs @@ -16,5 +16,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent [JsonProperty(PropertyName = "max_ratio_act")] public bool RemoveOnMaxRatio { get; set; } // Action performed when a torrent reaches the maximum share ratio. [false = pause, true = remove] + + [JsonProperty(PropertyName = "queueing_enabled")] + public bool QueueingEnabled { get; set; } = true; } } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs index e00c57585..92af1606f 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs @@ -72,7 +72,13 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent .Post() .AddFormParameter("urls", torrentUrl); - ProcessRequest(request, settings); + var result = ProcessRequest(request, settings); + + // Note: Older qbit versions returned nothing, so we can't do != "Ok." here. + if (result == "Fails.") + { + throw new DownloadClientException("Download client failed to add torrent by url"); + } } public void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings) @@ -81,7 +87,13 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent .Post() .AddFormUpload("torrents", fileName, fileContent); - ProcessRequest(request, settings); + var result = ProcessRequest(request, settings); + + // Note: Current qbit versions return nothing, so we can't do != "Ok." here. + if (result == "Fails.") + { + throw new DownloadClientException("Download client failed to add torrent"); + } } public void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings) @@ -90,7 +102,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent .Post() .AddFormParameter("hashes", hash); - ProcessRequest(request, settings); + ProcessRequest(request, settings); } public void SetTorrentLabel(string hash, string label, QBittorrentSettings settings) @@ -101,7 +113,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent .AddFormParameter("category", label); try { - ProcessRequest(setCategoryRequest, settings); + ProcessRequest(setCategoryRequest, settings); } catch(DownloadClientException ex) { @@ -112,7 +124,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent .Post() .AddFormParameter("hashes", hash) .AddFormParameter("label", label); - ProcessRequest(setLabelRequest, settings); + + ProcessRequest(setLabelRequest, settings); } } } @@ -125,7 +138,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent try { - var response = ProcessRequest(request, settings); + ProcessRequest(request, settings); } catch (DownloadClientException ex) { @@ -152,10 +165,18 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent private TResult ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) where TResult : new() + { + var responseContent = ProcessRequest(requestBuilder, settings); + + return Json.Deserialize(responseContent); + } + + private string ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) { AuthenticateClient(requestBuilder, settings); var request = requestBuilder.Build(); + request.LogResponseContent = true; HttpResponse response; try @@ -184,7 +205,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent throw new DownloadClientException("Failed to connect to qBitTorrent, please check your settings.", ex); } - return Json.Deserialize(response.Content); + return response.Content; } private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSettings settings, bool reauthenticate = false) @@ -225,7 +246,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } catch (WebException ex) { - throw new DownloadClientException("Failed to connect to qBitTorrent, please check your settings.", ex); + throw new DownloadClientUnavailableException("Failed to connect to qBitTorrent, please check your settings.", ex); } if (response.Content != "Ok.") // returns "Fails." on bad login diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs index 70ae19373..f0fc9dde4 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -57,7 +57,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd } catch (DownloadClientException ex) { - _logger.Warn("Couldn't get download queue. {0}", ex.Message); + _logger.Warn(ex, "Couldn't get download queue. {0}", ex.Message); return Enumerable.Empty(); } @@ -78,6 +78,8 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd queueItem.TotalSize = (long)(sabQueueItem.Size * 1024 * 1024); queueItem.RemainingSize = (long)(sabQueueItem.Sizeleft * 1024 * 1024); queueItem.RemainingTime = sabQueueItem.Timeleft; + queueItem.CanBeRemoved = true; + queueItem.CanMoveFiles = true; if (sabQueue.Paused || sabQueueItem.Status == SabnzbdDownloadStatus.Paused) { @@ -110,17 +112,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd private IEnumerable GetHistory() { - SabnzbdHistory sabHistory; - - try - { - sabHistory = _proxy.GetHistory(0, _configService.DownloadClientHistoryLimit, Settings.TvCategory, Settings); - } - catch (DownloadClientException ex) - { - _logger.Error(ex); - return Enumerable.Empty(); - } + var sabHistory = _proxy.GetHistory(0, _configService.DownloadClientHistoryLimit, Settings.TvCategory, Settings); var historyItems = new List(); @@ -142,7 +134,10 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd RemainingSize = 0, RemainingTime = TimeSpan.Zero, - Message = sabHistoryItem.FailMessage + Message = sabHistoryItem.FailMessage, + + CanBeRemoved = true, + CanMoveFiles = true }; if (sabHistoryItem.Status == SabnzbdDownloadStatus.Failed) @@ -183,6 +178,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd } } + historyItems.Add(historyItem); } @@ -244,7 +240,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd } } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { var config = _proxy.GetConfig(Settings); var categories = GetCategories(config).ToArray(); @@ -256,7 +252,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd category = categories.FirstOrDefault(v => v.Name == "*"); } - var status = new DownloadClientStatus + var status = new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" }; @@ -319,6 +315,11 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd private Version ParseVersion(string version) { + if (version.IsNullOrWhiteSpace()) + { + return null; + } + var parsed = VersionRegex.Match(version); int major; @@ -356,7 +357,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd if (version == null) { - return new ValidationFailure("Version", "Unknown Version: " + version); + return new ValidationFailure("Version", "Unknown Version: " + rawVersion); } if (rawVersion.Equals("develop", StringComparison.InvariantCultureIgnoreCase)) @@ -382,7 +383,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, ex.Message); return new ValidationFailure("Host", "Unable to connect to SABnzbd"); } } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs index 397771ff2..282f901d7 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs @@ -183,7 +183,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd } catch (WebException ex) { - throw new DownloadClientException("Unable to connect to SABnzbd, please check your settings", ex); + throw new DownloadClientUnavailableException("Unable to connect to SABnzbd, please check your settings", ex); } CheckForError(response); diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs index 3fa69c06b..8533f71dc 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -33,17 +33,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission public override IEnumerable GetItems() { - List torrents; - - try - { - torrents = _proxy.GetTorrents(Settings); - } - catch (DownloadClientException ex) - { - _logger.Error(ex); - return Enumerable.Empty(); - } + var torrents = _proxy.GetTorrents(Settings); var items = new List(); @@ -86,8 +76,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission item.Status = DownloadItemStatus.Warning; item.Message = torrent.ErrorString; } - else if (torrent.Status == TransmissionTorrentStatus.Seeding || - torrent.Status == TransmissionTorrentStatus.SeedingWait) + else if (torrent.LeftUntilDone == 0 && (torrent.Status == TransmissionTorrentStatus.Stopped || + torrent.Status == TransmissionTorrentStatus.Seeding || + torrent.Status == TransmissionTorrentStatus.SeedingWait)) { item.Status = DownloadItemStatus.Completed; } @@ -105,7 +96,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission item.Status = DownloadItemStatus.Downloading; } - item.IsReadOnly = torrent.Status != TransmissionTorrentStatus.Stopped; + item.CanMoveFiles = item.CanBeRemoved = torrent.Status == TransmissionTorrentStatus.Stopped; items.Add(item); } @@ -118,17 +109,17 @@ namespace NzbDrone.Core.Download.Clients.Transmission _proxy.RemoveTorrent(downloadId.ToLower(), deleteData, Settings); } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { var config = _proxy.GetConfig(Settings); var destDir = config.GetValueOrDefault("download-dir") as string; - + if (Settings.TvCategory.IsNotNullOrWhiteSpace()) { destDir = string.Format("{0}/.{1}", destDir, Settings.TvCategory); } - return new DownloadClientStatus + return new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost", OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(destDir)) } @@ -204,27 +195,24 @@ namespace NzbDrone.Core.Download.Clients.Transmission } catch (DownloadClientAuthenticationException ex) { - _logger.Error(ex); + _logger.Error(ex, ex.Message); return new NzbDroneValidationFailure("Username", "Authentication failure") { DetailedDescription = string.Format("Please verify your username and password. Also verify if the host running Sonarr isn't blocked from accessing {0} by WhiteList limitations in the {0} configuration.", Name) }; } - catch (WebException ex) + catch (DownloadClientUnavailableException ex) { - _logger.Error(ex); - if (ex.Status == WebExceptionStatus.ConnectFailure) + _logger.Error(ex, ex.Message); + + return new NzbDroneValidationFailure("Host", "Unable to connect") { - return new NzbDroneValidationFailure("Host", "Unable to connect") - { - DetailedDescription = "Please verify the hostname and port." - }; - } - return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); + DetailedDescription = "Please verify the hostname and port." + }; } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Failed to test"); return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); } } @@ -239,11 +227,11 @@ namespace NzbDrone.Core.Download.Clients.Transmission } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Failed to get torrents"); return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); } return null; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs index cada83cae..5d40f355f 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs @@ -238,54 +238,66 @@ namespace NzbDrone.Core.Download.Clients.Transmission public TransmissionResponse ProcessRequest(string action, object arguments, TransmissionSettings settings) { - var requestBuilder = BuildRequest(settings); - requestBuilder.Headers.ContentType = "application/json"; - requestBuilder.SuppressHttpError = true; - - AuthenticateClient(requestBuilder, settings); - - var request = requestBuilder.Post().Build(); - - var data = new Dictionary(); - data.Add("method", action); - - if (arguments != null) + try { - data.Add("arguments", arguments); - } + var requestBuilder = BuildRequest(settings); + requestBuilder.Headers.ContentType = "application/json"; + requestBuilder.SuppressHttpError = true; - request.SetContent(data.ToJson()); - request.ContentSummary = string.Format("{0}(...)", action); + AuthenticateClient(requestBuilder, settings); - var response = _httpClient.Execute(request); - if (response.StatusCode == HttpStatusCode.Conflict) - { - AuthenticateClient(requestBuilder, settings, true); + var request = requestBuilder.Post().Build(); - request = requestBuilder.Post().Build(); + var data = new Dictionary(); + data.Add("method", action); + + if (arguments != null) + { + data.Add("arguments", arguments); + } request.SetContent(data.ToJson()); request.ContentSummary = string.Format("{0}(...)", action); - response = _httpClient.Execute(request); - } - else if (response.StatusCode == HttpStatusCode.Unauthorized) - { - throw new DownloadClientAuthenticationException("User authentication failed."); - } + var response = _httpClient.Execute(request); - var transmissionResponse = Json.Deserialize(response.Content); + if (response.StatusCode == HttpStatusCode.Conflict) + { + AuthenticateClient(requestBuilder, settings, true); - if (transmissionResponse == null) - { - throw new TransmissionException("Unexpected response"); - } - else if (transmissionResponse.Result != "success") - { - throw new TransmissionException(transmissionResponse.Result); - } + request = requestBuilder.Post().Build(); - return transmissionResponse; + request.SetContent(data.ToJson()); + request.ContentSummary = string.Format("{0}(...)", action); + + response = _httpClient.Execute(request); + } + else if (response.StatusCode == HttpStatusCode.Unauthorized) + { + throw new DownloadClientAuthenticationException("User authentication failed."); + } + + var transmissionResponse = Json.Deserialize(response.Content); + + if (transmissionResponse == null) + { + throw new TransmissionException("Unexpected response"); + } + else if (transmissionResponse.Result != "success") + { + throw new TransmissionException(transmissionResponse.Result); + } + + return transmissionResponse; + } + catch (HttpException ex) + { + throw new DownloadClientException("Unable to connect to Transmission, please check your settings", ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Unable to connect to Transmission, please check your settings", ex); + } } } } diff --git a/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs b/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs index 1da02e835..dc3bb712e 100644 --- a/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs +++ b/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs @@ -26,7 +26,19 @@ namespace NzbDrone.Core.Download.Clients.Vuze protected override OsPath GetOutputPath(OsPath outputPath, TransmissionTorrent torrent) { - _logger.Debug("Vuze output directory: {0}", outputPath); + // Vuze has similar behavior as uTorrent: + // - A multi-file torrent is downloaded in a job folder and 'outputPath' points to that directory directly. + // - A single-file torrent is downloaded in the root folder and 'outputPath' poinst to that root folder. + // We have to make sure the return value points to the job folder OR file. + if (outputPath == null || outputPath.FileName == torrent.Name) + { + _logger.Trace("Vuze output directory: {0}", outputPath); + } + else + { + outputPath = outputPath + torrent.Name; + _logger.Trace("Vuze output file: {0}", outputPath); + } return outputPath; } @@ -50,4 +62,4 @@ namespace NzbDrone.Core.Download.Clients.Vuze public override string Name => "Vuze"; } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs index ee384fc92..95fb2db19 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -81,57 +81,60 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public override IEnumerable GetItems() { - try + var torrents = _proxy.GetTorrents(Settings); + + _logger.Debug("Retrieved metadata of {0} torrents in client", torrents.Count); + + var items = new List(); + foreach (RTorrentTorrent torrent in torrents) { - var torrents = _proxy.GetTorrents(Settings); + // Don't concern ourselves with categories other than specified + if (torrent.Category != Settings.TvCategory) continue; - _logger.Debug("Retrieved metadata of {0} torrents in client", torrents.Count); - - var items = new List(); - foreach (RTorrentTorrent torrent in torrents) + if (torrent.Path.StartsWith(".")) { - // Don't concern ourselves with categories other than specified - if (torrent.Category != Settings.TvCategory) continue; - - if (torrent.Path.StartsWith(".")) - { - throw new DownloadClientException("Download paths paths must be absolute. Please specify variable \"directory\" in rTorrent."); - } - - var item = new DownloadClientItem(); - item.DownloadClient = Definition.Name; - item.Title = torrent.Name; - item.DownloadId = torrent.Hash; - item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.Path)); - item.TotalSize = torrent.TotalSize; - item.RemainingSize = torrent.RemainingSize; - item.Category = torrent.Category; - - if (torrent.DownRate > 0) { - var secondsLeft = torrent.RemainingSize / torrent.DownRate; - item.RemainingTime = TimeSpan.FromSeconds(secondsLeft); - } else { - item.RemainingTime = TimeSpan.Zero; - } - - if (torrent.IsFinished) item.Status = DownloadItemStatus.Completed; - else if (torrent.IsActive) item.Status = DownloadItemStatus.Downloading; - else if (!torrent.IsActive) item.Status = DownloadItemStatus.Paused; - - // No stop ratio data is present, so do not delete - item.IsReadOnly = true; - - items.Add(item); + throw new DownloadClientException("Download paths paths must be absolute. Please specify variable \"directory\" in rTorrent."); } - return items; - } - catch (DownloadClientException ex) - { - _logger.Error(ex); - return Enumerable.Empty(); + var item = new DownloadClientItem(); + item.DownloadClient = Definition.Name; + item.Title = torrent.Name; + item.DownloadId = torrent.Hash; + item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.Path)); + item.TotalSize = torrent.TotalSize; + item.RemainingSize = torrent.RemainingSize; + item.Category = torrent.Category; + + if (torrent.DownRate > 0) + { + var secondsLeft = torrent.RemainingSize / torrent.DownRate; + item.RemainingTime = TimeSpan.FromSeconds(secondsLeft); + } + else + { + item.RemainingTime = TimeSpan.Zero; + } + + if (torrent.IsFinished) + { + item.Status = DownloadItemStatus.Completed; + } + else if (torrent.IsActive) + { + item.Status = DownloadItemStatus.Downloading; + } + else if (!torrent.IsActive) + { + item.Status = DownloadItemStatus.Paused; + } + + // No stop ratio data is present, so do not delete + item.CanMoveFiles = item.CanBeRemoved = false; + + items.Add(item); } + return items; } public override void RemoveItem(string downloadId, bool deleteData) @@ -144,11 +147,11 @@ namespace NzbDrone.Core.Download.Clients.RTorrent _proxy.RemoveTorrent(downloadId, Settings); } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { // XXX: This function's correctness has not been considered - var status = new DownloadClientStatus + var status = new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" }; @@ -177,7 +180,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Failed to test rTorrent"); return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); } @@ -192,7 +195,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Failed to get torrents"); return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); } diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs index 749a68d7a..68a0f80ea 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices.ComTypes; using NLog; using NzbDrone.Common.Extensions; using CookComputing.XmlRpc; @@ -54,8 +56,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent _logger.Debug("Executing remote method: system.client_version"); var client = BuildClient(settings); - - var version = client.GetVersion(); + var version = ExecuteRequest(() => client.GetVersion()); return version; } @@ -65,20 +66,22 @@ namespace NzbDrone.Core.Download.Clients.RTorrent _logger.Debug("Executing remote method: d.multicall2"); var client = BuildClient(settings); - var ret = client.TorrentMulticall("", "", - "d.name=", // string - "d.hash=", // string - "d.base_path=", // string - "d.custom1=", // string (label) - "d.size_bytes=", // long - "d.left_bytes=", // long - "d.down.rate=", // long (in bytes / s) - "d.ratio=", // long - "d.is_open=", // long - "d.is_active=", // long - "d.complete="); //long + var ret = ExecuteRequest(() => client.TorrentMulticall("", "", + "d.name=", // string + "d.hash=", // string + "d.base_path=", // string + "d.custom1=", // string (label) + "d.size_bytes=", // long + "d.left_bytes=", // long + "d.down.rate=", // long (in bytes / s) + "d.ratio=", // long + "d.is_open=", // long + "d.is_active=", // long + "d.complete=") //long + ); var items = new List(); + foreach (object[] torrent in ret) { var labelDecoded = System.Web.HttpUtility.UrlDecode((string) torrent[3]); @@ -107,8 +110,8 @@ namespace NzbDrone.Core.Download.Clients.RTorrent _logger.Debug("Executing remote method: load.normal"); var client = BuildClient(settings); + var response = ExecuteRequest(() => client.LoadStart("", torrentUrl, GetCommands(label, priority, directory))); - var response = client.LoadStart("", torrentUrl, GetCommands(label, priority, directory)); if (response != 0) { throw new DownloadClientException("Could not add torrent: {0}.", torrentUrl); @@ -120,8 +123,8 @@ namespace NzbDrone.Core.Download.Clients.RTorrent _logger.Debug("Executing remote method: load.raw"); var client = BuildClient(settings); + var response = ExecuteRequest(() => client.LoadRawStart("", fileContent, GetCommands(label, priority, directory))); - var response = client.LoadRawStart("", fileContent, GetCommands(label, priority, directory)); if (response != 0) { throw new DownloadClientException("Could not add torrent: {0}.", fileName); @@ -133,14 +136,39 @@ namespace NzbDrone.Core.Download.Clients.RTorrent _logger.Debug("Executing remote method: d.erase"); var client = BuildClient(settings); + var response = ExecuteRequest(() => client.Remove(hash)); - var response = client.Remove(hash); if (response != 0) { throw new DownloadClientException("Could not remove torrent: {0}.", hash); } } + public bool HasHashTorrent(string hash, RTorrentSettings settings) + { + _logger.Debug("Executing remote method: d.name"); + + var client = BuildClient(settings); + + try + { + var name = ExecuteRequest(() => client.GetName(hash)); + + if (name.IsNullOrWhiteSpace()) + { + return false; + } + + var metaTorrent = name == (hash + ".meta"); + + return !metaTorrent; + } + catch (Exception) + { + return false; + } + } + private string[] GetCommands(string label, RTorrentPriority priority, string directory) { var result = new List(); @@ -163,25 +191,6 @@ namespace NzbDrone.Core.Download.Clients.RTorrent return result.ToArray(); } - public bool HasHashTorrent(string hash, RTorrentSettings settings) - { - _logger.Debug("Executing remote method: d.name"); - - var client = BuildClient(settings); - - try - { - var name = client.GetName(hash); - if (name.IsNullOrWhiteSpace()) return false; - bool metaTorrent = name == (hash + ".meta"); - return !metaTorrent; - } - catch (Exception) - { - return false; - } - } - private IRTorrent BuildClient(RTorrentSettings settings) { var client = XmlRpcProxyGen.Create(); @@ -201,5 +210,21 @@ namespace NzbDrone.Core.Download.Clients.RTorrent return client; } + + private T ExecuteRequest(Func task) + { + try + { + return task(); + } + catch (XmlRpcServerException ex) + { + throw new DownloadClientException("Unable to connect to rTorrent, please check your settings", ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Unable to connect to rTorrent, please check your settings", ex); + } + } } } diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs index 8f442eb7b..81dfff90b 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs @@ -72,42 +72,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent public override IEnumerable GetItems() { - List torrents; - - try - { - var cacheKey = string.Format("{0}:{1}:{2}", Settings.Host, Settings.Port, Settings.TvCategory); - var cache = _torrentCache.Find(cacheKey); - - var response = _proxy.GetTorrents(cache == null ? null : cache.CacheID, Settings); - - if (cache != null && response.Torrents == null) - { - var removedAndUpdated = new HashSet(response.TorrentsChanged.Select(v => v.Hash).Concat(response.TorrentsRemoved)); - - torrents = cache.Torrents - .Where(v => !removedAndUpdated.Contains(v.Hash)) - .Concat(response.TorrentsChanged) - .ToList(); - } - else - { - torrents = response.Torrents; - } - - cache = new UTorrentTorrentCache - { - CacheID = response.CacheNumber, - Torrents = torrents - }; - - _torrentCache.Set(cacheKey, cache, TimeSpan.FromMinutes(15)); - } - catch (DownloadClientException ex) - { - _logger.Error(ex); - return Enumerable.Empty(); - } + var torrents = GetTorrents(); var queueItems = new List(); @@ -165,7 +130,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent } // 'Started' without 'Queued' is when the torrent is 'forced seeding' - item.IsReadOnly = torrent.Status.HasFlag(UTorrentTorrentStatus.Queued) || torrent.Status.HasFlag(UTorrentTorrentStatus.Started); + item.CanMoveFiles = item.CanBeRemoved = (!torrent.Status.HasFlag(UTorrentTorrentStatus.Queued) && !torrent.Status.HasFlag(UTorrentTorrentStatus.Started)); queueItems.Add(item); } @@ -173,12 +138,46 @@ namespace NzbDrone.Core.Download.Clients.UTorrent return queueItems; } + private List GetTorrents() + { + List torrents; + + var cacheKey = string.Format("{0}:{1}:{2}", Settings.Host, Settings.Port, Settings.TvCategory); + var cache = _torrentCache.Find(cacheKey); + + var response = _proxy.GetTorrents(cache == null ? null : cache.CacheID, Settings); + + if (cache != null && response.Torrents == null) + { + var removedAndUpdated = new HashSet(response.TorrentsChanged.Select(v => v.Hash).Concat(response.TorrentsRemoved)); + + torrents = cache.Torrents + .Where(v => !removedAndUpdated.Contains(v.Hash)) + .Concat(response.TorrentsChanged) + .ToList(); + } + else + { + torrents = response.Torrents; + } + + cache = new UTorrentTorrentCache + { + CacheID = response.CacheNumber, + Torrents = torrents + }; + + _torrentCache.Set(cacheKey, cache, TimeSpan.FromMinutes(15)); + + return torrents; + } + public override void RemoveItem(string downloadId, bool deleteData) { _proxy.RemoveTorrent(downloadId, deleteData, Settings); } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { var config = _proxy.GetConfig(Settings); @@ -199,7 +198,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent } } - var status = new DownloadClientStatus + var status = new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" }; @@ -232,7 +231,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent } catch (DownloadClientAuthenticationException ex) { - _logger.Error(ex); + _logger.Error(ex, ex.Message); return new NzbDroneValidationFailure("Username", "Authentication failure") { DetailedDescription = "Please verify your username and password." @@ -240,7 +239,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent } catch (WebException ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to connect to uTorrent"); if (ex.Status == WebExceptionStatus.ConnectFailure) { return new NzbDroneValidationFailure("Host", "Unable to connect") @@ -252,7 +251,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Failed to test uTorrent"); return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); } @@ -267,7 +266,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Failed to get torrents"); return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); } diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs index 64117f328..123e121e0 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs @@ -244,7 +244,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent } catch (WebException ex) { - throw new DownloadClientException("Unable to connect to uTorrent, please check your settings", ex); + throw new DownloadClientUnavailableException("Unable to connect to uTorrent, please check your settings", ex); } cookies = response.GetCookies(); diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index 69c68bff3..713935a4c 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -60,7 +60,7 @@ namespace NzbDrone.Core.Download public abstract string Download(RemoteEpisode remoteEpisode); public abstract IEnumerable GetItems(); public abstract void RemoveItem(string downloadId, bool deleteData); - public abstract DownloadClientStatus GetStatus(); + public abstract DownloadClientInfo GetStatus(); protected virtual void DeleteItemData(string downloadId) { diff --git a/src/NzbDrone.Core/Download/DownloadClientFactory.cs b/src/NzbDrone.Core/Download/DownloadClientFactory.cs index dc0f218b5..8ba7be505 100644 --- a/src/NzbDrone.Core/Download/DownloadClientFactory.cs +++ b/src/NzbDrone.Core/Download/DownloadClientFactory.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using FluentValidation.Results; using NLog; using NzbDrone.Common.Composition; using NzbDrone.Core.Messaging.Events; @@ -9,17 +11,24 @@ namespace NzbDrone.Core.Download { public interface IDownloadClientFactory : IProviderFactory { - + List DownloadHandlingEnabled(bool filterBlockedClients = true); } public class DownloadClientFactory : ProviderFactory, IDownloadClientFactory { - private readonly IDownloadClientRepository _providerRepository; + private readonly IDownloadClientStatusService _downloadClientStatusService; + private readonly Logger _logger; - public DownloadClientFactory(IDownloadClientRepository providerRepository, IEnumerable providers, IContainer container, IEventAggregator eventAggregator, Logger logger) + public DownloadClientFactory(IDownloadClientStatusService downloadClientStatusService, + IDownloadClientRepository providerRepository, + IEnumerable providers, + IContainer container, + IEventAggregator eventAggregator, + Logger logger) : base(providerRepository, providers, container, eventAggregator, logger) { - _providerRepository = providerRepository; + _downloadClientStatusService = downloadClientStatusService; + _logger = logger; } protected override List Active() @@ -33,5 +42,46 @@ namespace NzbDrone.Core.Download definition.Protocol = provider.Protocol; } + + public List DownloadHandlingEnabled(bool filterBlockedClients = true) + { + var enabledClients = GetAvailableProviders(); + + if (filterBlockedClients) + { + return FilterBlockedClients(enabledClients).ToList(); + } + + return enabledClients.ToList(); + } + + private IEnumerable FilterBlockedClients(IEnumerable clients) + { + var blockedIndexers = _downloadClientStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId, v => v); + + foreach (var client in clients) + { + DownloadClientStatus downloadClientStatus; + if (blockedIndexers.TryGetValue(client.Definition.Id, out downloadClientStatus)) + { + _logger.Debug("Temporarily ignoring download client {0} till {1} due to recent failures.", client.Definition.Name, downloadClientStatus.DisabledTill.Value.ToLocalTime()); + continue; + } + + yield return client; + } + } + + public override ValidationResult Test(DownloadClientDefinition definition) + { + var result = base.Test(definition); + + if ((result == null || result.IsValid) && definition.Id != 0) + { + _downloadClientStatusService.RecordSuccess(definition.Id); + } + + return result; + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Download/DownloadClientInfo.cs b/src/NzbDrone.Core/Download/DownloadClientInfo.cs new file mode 100644 index 000000000..cf586ab64 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientInfo.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using NzbDrone.Common.Disk; + +namespace NzbDrone.Core.Download +{ + public class DownloadClientInfo + { + public bool IsLocalhost { get; set; } + public List OutputRootFolders { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientItem.cs b/src/NzbDrone.Core/Download/DownloadClientItem.cs index 2e0533e50..acd0b0579 100644 --- a/src/NzbDrone.Core/Download/DownloadClientItem.cs +++ b/src/NzbDrone.Core/Download/DownloadClientItem.cs @@ -21,7 +21,9 @@ namespace NzbDrone.Core.Download public DownloadItemStatus Status { get; set; } public bool IsEncrypted { get; set; } - public bool IsReadOnly { get; set; } + + public bool CanMoveFiles { get; set; } + public bool CanBeRemoved { get; set; } public bool Removed { get; set; } } diff --git a/src/NzbDrone.Core/Download/DownloadClientProvider.cs b/src/NzbDrone.Core/Download/DownloadClientProvider.cs index 5cb899806..0c8ab03ba 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -27,19 +27,12 @@ namespace NzbDrone.Core.Download public IEnumerable GetDownloadClients() { - return _downloadClientFactory.GetAvailableProviders();//.Select(MapDownloadClient); + return _downloadClientFactory.GetAvailableProviders(); } public IDownloadClient Get(int id) { return _downloadClientFactory.GetAvailableProviders().Single(d => d.Definition.Id == id); } - - public IDownloadClient MapDownloadClient(IDownloadClient downloadClient) - { - _downloadClientFactory.SetProviderCharacteristics(downloadClient, (DownloadClientDefinition)downloadClient.Definition); - - return downloadClient; - } } } diff --git a/src/NzbDrone.Core/Download/DownloadClientStatus.cs b/src/NzbDrone.Core/Download/DownloadClientStatus.cs index a092fd8de..455196672 100644 --- a/src/NzbDrone.Core/Download/DownloadClientStatus.cs +++ b/src/NzbDrone.Core/Download/DownloadClientStatus.cs @@ -1,11 +1,9 @@ -using System.Collections.Generic; -using NzbDrone.Common.Disk; +using NzbDrone.Core.ThingiProvider.Status; namespace NzbDrone.Core.Download { - public class DownloadClientStatus + public class DownloadClientStatus : ProviderStatusBase { - public bool IsLocalhost { get; set; } - public List OutputRootFolders { get; set; } + } } diff --git a/src/NzbDrone.Core/Download/DownloadClientStatusRepository.cs b/src/NzbDrone.Core/Download/DownloadClientStatusRepository.cs new file mode 100644 index 000000000..4f6fd6dfa --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientStatusRepository.cs @@ -0,0 +1,19 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.Download +{ + public interface IDownloadClientStatusRepository : IProviderStatusRepository + { + + } + + public class DownloadClientStatusRepository : ProviderStatusRepository, IDownloadClientStatusRepository + { + public DownloadClientStatusRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientStatusService.cs b/src/NzbDrone.Core/Download/DownloadClientStatusService.cs new file mode 100644 index 000000000..b4fd0f83c --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientStatusService.cs @@ -0,0 +1,22 @@ +using System; +using NLog; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.Download +{ + public interface IDownloadClientStatusService : IProviderStatusServiceBase + { + + } + + public class DownloadClientStatusService : ProviderStatusServiceBase, IDownloadClientStatusService + { + public DownloadClientStatusService(IDownloadClientStatusRepository providerStatusRepository, IEventAggregator eventAggregator, Logger logger) + : base(providerStatusRepository, eventAggregator, logger) + { + MinimumTimeSinceInitialFailure = TimeSpan.FromMinutes(5); + MaximumEscalationLevel = 5; + } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadEventHub.cs b/src/NzbDrone.Core/Download/DownloadEventHub.cs index f738f5c2e..0168b013e 100644 --- a/src/NzbDrone.Core/Download/DownloadEventHub.cs +++ b/src/NzbDrone.Core/Download/DownloadEventHub.cs @@ -37,7 +37,7 @@ namespace NzbDrone.Core.Download { if (!_configService.RemoveCompletedDownloads || message.TrackedDownload.DownloadItem.Removed || - message.TrackedDownload.DownloadItem.IsReadOnly || + !message.TrackedDownload.DownloadItem.CanBeRemoved || message.TrackedDownload.DownloadItem.Status == DownloadItemStatus.Downloading) { return; @@ -50,7 +50,7 @@ namespace NzbDrone.Core.Download { var trackedDownload = message.TrackedDownload; - if (trackedDownload == null || trackedDownload.DownloadItem.IsReadOnly || _configService.RemoveFailedDownloads == false) + if (trackedDownload == null || !trackedDownload.DownloadItem.CanBeRemoved || _configService.RemoveFailedDownloads == false) { return; } @@ -78,4 +78,4 @@ namespace NzbDrone.Core.Download } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Download/DownloadService.cs b/src/NzbDrone.Core/Download/DownloadService.cs index 4f76b1507..6580002e8 100644 --- a/src/NzbDrone.Core/Download/DownloadService.cs +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -21,18 +21,21 @@ namespace NzbDrone.Core.Download public class DownloadService : IDownloadService { private readonly IProvideDownloadClient _downloadClientProvider; + private readonly IDownloadClientStatusService _downloadClientStatusService; private readonly IIndexerStatusService _indexerStatusService; private readonly IRateLimitService _rateLimitService; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; public DownloadService(IProvideDownloadClient downloadClientProvider, - IIndexerStatusService indexerStatusService, - IRateLimitService rateLimitService, - IEventAggregator eventAggregator, - Logger logger) + IDownloadClientStatusService downloadClientStatusService, + IIndexerStatusService indexerStatusService, + IRateLimitService rateLimitService, + IEventAggregator eventAggregator, + Logger logger) { _downloadClientProvider = downloadClientProvider; + _downloadClientStatusService = downloadClientStatusService; _indexerStatusService = indexerStatusService; _rateLimitService = rateLimitService; _eventAggregator = eventAggregator; @@ -64,6 +67,7 @@ namespace NzbDrone.Core.Download try { downloadClientId = downloadClient.Download(remoteEpisode); + _downloadClientStatusService.RecordSuccess(downloadClient.Definition.Id); _indexerStatusService.RecordSuccess(remoteEpisode.Release.IndexerId); } catch (ReleaseDownloadException ex) @@ -92,4 +96,4 @@ namespace NzbDrone.Core.Download _eventAggregator.PublishEvent(episodeGrabbedEvent); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Download/IDownloadClient.cs b/src/NzbDrone.Core/Download/IDownloadClient.cs index 6703d8a22..4b09535af 100644 --- a/src/NzbDrone.Core/Download/IDownloadClient.cs +++ b/src/NzbDrone.Core/Download/IDownloadClient.cs @@ -12,6 +12,6 @@ namespace NzbDrone.Core.Download string Download(RemoteEpisode remoteEpisode); IEnumerable GetItems(); void RemoveItem(string downloadId, bool deleteData); - DownloadClientStatus GetStatus(); + DownloadClientInfo GetStatus(); } } diff --git a/src/NzbDrone.Core/Download/Pending/PendingRelease.cs b/src/NzbDrone.Core/Download/Pending/PendingRelease.cs index a713fe48c..93c75a669 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingRelease.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingRelease.cs @@ -11,6 +11,7 @@ namespace NzbDrone.Core.Download.Pending public DateTime Added { get; set; } public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } public ReleaseInfo Release { get; set; } + public PendingReleaseReason Reason { get; set; } //Not persisted public RemoteEpisode RemoteEpisode { get; set; } diff --git a/src/NzbDrone.Core/Download/Pending/PendingReleaseReason.cs b/src/NzbDrone.Core/Download/Pending/PendingReleaseReason.cs new file mode 100644 index 000000000..a6d9b06f8 --- /dev/null +++ b/src/NzbDrone.Core/Download/Pending/PendingReleaseReason.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Download.Pending +{ + public enum PendingReleaseReason + { + Delay = 0, + DownloadClientUnavailable = 1, + Fallback = 2 + } +} diff --git a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs index 8585a1704..c23890b01 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs @@ -20,8 +20,7 @@ namespace NzbDrone.Core.Download.Pending { public interface IPendingReleaseService { - void Add(DownloadDecision decision); - + void Add(DownloadDecision decision, PendingReleaseReason reason); List GetPending(); List GetPendingRemoteEpisodes(int seriesId); List GetPendingQueue(); @@ -67,7 +66,7 @@ namespace NzbDrone.Core.Download.Pending } - public void Add(DownloadDecision decision) + public void Add(DownloadDecision decision, PendingReleaseReason reason) { var alreadyPending = GetPendingReleases(); @@ -77,14 +76,32 @@ namespace NzbDrone.Core.Download.Pending .Intersect(episodeIds) .Any()); - if (existingReports.Any(MatchingReleasePredicate(decision.RemoteEpisode.Release))) + var matchingReports = existingReports.Where(MatchingReleasePredicate(decision.RemoteEpisode.Release)).ToList(); + + if (matchingReports.Any()) { - _logger.Debug("This release is already pending, not adding again"); - return; + var sameReason = true; + + foreach (var matchingReport in matchingReports) + { + if (matchingReport.Reason != reason) + { + _logger.Debug("The release {0} is already pending with reason {1}, changing to {2}", decision.RemoteEpisode, matchingReport.Reason, reason); + matchingReport.Reason = reason; + _repository.Update(matchingReport); + sameReason = false; + } + } + + if (sameReason) + { + _logger.Debug("The release {0} is already pending with reason {1}, not adding again", decision.RemoteEpisode, reason); + return; + } } - _logger.Debug("Adding release to pending releases"); - Insert(decision); + _logger.Debug("Adding release {0} to pending releases with reason {1}", decision.RemoteEpisode, reason); + Insert(decision, reason); } public List GetPending() @@ -101,7 +118,7 @@ namespace NzbDrone.Core.Download.Pending private List FilterBlockedIndexers(List releases) { - var blockedIndexers = new HashSet(_indexerStatusService.GetBlockedIndexers().Select(v => v.IndexerId)); + var blockedIndexers = new HashSet(_indexerStatusService.GetBlockedProviders().Select(v => v.ProviderId)); return releases.Where(release => !blockedIndexers.Contains(release.IndexerId)).ToList(); } @@ -117,7 +134,7 @@ namespace NzbDrone.Core.Download.Pending var nextRssSync = new Lazy(() => _taskManager.GetNextExecution(typeof(RssSyncCommand))); - foreach (var pendingRelease in GetPendingReleases()) + foreach (var pendingRelease in GetPendingReleases().Where(p => p.Reason != PendingReleaseReason.Fallback)) { foreach (var episode in pendingRelease.RemoteEpisode.Episodes) { @@ -132,6 +149,13 @@ namespace NzbDrone.Core.Download.Pending ect = ect.AddMinutes(_configService.RssSyncInterval); } + var timeleft = ect.Subtract(DateTime.UtcNow); + + if (timeleft.TotalSeconds < 0) + { + timeleft = TimeSpan.Zero; + } + var queue = new Queue.Queue { Id = GetQueueId(pendingRelease, episode), @@ -142,11 +166,12 @@ namespace NzbDrone.Core.Download.Pending Size = pendingRelease.RemoteEpisode.Release.Size, Sizeleft = pendingRelease.RemoteEpisode.Release.Size, RemoteEpisode = pendingRelease.RemoteEpisode, - Timeleft = ect.Subtract(DateTime.UtcNow), + Timeleft = timeleft, EstimatedCompletionTime = ect, - Status = "Pending", + Status = pendingRelease.Reason.ToString(), Protocol = pendingRelease.RemoteEpisode.Release.DownloadProtocol }; + queued.Add(queue); } } @@ -224,7 +249,7 @@ namespace NzbDrone.Core.Download.Pending }; } - private void Insert(DownloadDecision decision) + private void Insert(DownloadDecision decision, PendingReleaseReason reason) { _repository.Insert(new PendingRelease { @@ -232,7 +257,8 @@ namespace NzbDrone.Core.Download.Pending ParsedEpisodeInfo = decision.RemoteEpisode.ParsedEpisodeInfo, Release = decision.RemoteEpisode.Release, Title = decision.RemoteEpisode.Release.Title, - Added = DateTime.UtcNow + Added = DateTime.UtcNow, + Reason = reason }); _eventAggregator.PublishEvent(new PendingReleasesUpdatedEvent()); diff --git a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs index 05719587d..bd146dff7 100644 --- a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs +++ b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using NLog; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Indexers; namespace NzbDrone.Core.Download { @@ -36,36 +39,33 @@ namespace NzbDrone.Core.Download var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(qualifiedReports); var grabbed = new List(); var pending = new List(); + var failed = new List(); + + var usenetFailed = false; + var torrentFailed = false; foreach (var report in prioritizedDecisions) { var remoteEpisode = report.RemoteEpisode; + var downloadProtocol = report.RemoteEpisode.Release.DownloadProtocol; - var episodeIds = remoteEpisode.Episodes.Select(e => e.Id).ToList(); - - //Skip if already grabbed - if (grabbed.SelectMany(r => r.RemoteEpisode.Episodes) - .Select(e => e.Id) - .ToList() - .Intersect(episodeIds) - .Any()) + // Skip if already grabbed + if (IsEpisodeProcessed(grabbed, report)) { continue; } if (report.TemporarilyRejected) { - _pendingReleaseService.Add(report); + _pendingReleaseService.Add(report, PendingReleaseReason.Delay); pending.Add(report); continue; } - if (pending.SelectMany(r => r.RemoteEpisode.Episodes) - .Select(e => e.Id) - .ToList() - .Intersect(episodeIds) - .Any()) + if (downloadProtocol == DownloadProtocol.Usenet && usenetFailed || + downloadProtocol == DownloadProtocol.Torrent && torrentFailed) { + failed.Add(report); continue; } @@ -74,14 +74,31 @@ namespace NzbDrone.Core.Download _downloadService.DownloadReport(remoteEpisode); grabbed.Add(report); } - catch (Exception e) + catch (Exception ex) { - //TODO: support for store & forward - //We'll need to differentiate between a download client error and an indexer error - _logger.Warn(e, "Couldn't add report to download queue. " + remoteEpisode); + if (ex is DownloadClientUnavailableException || ex is DownloadClientAuthenticationException) + { + _logger.Debug(ex, "Failed to send release to download client, storing until later. " + remoteEpisode); + failed.Add(report); + + if (downloadProtocol == DownloadProtocol.Usenet) + { + usenetFailed = true; + } + else if (downloadProtocol == DownloadProtocol.Torrent) + { + torrentFailed = true; + } + } + else + { + _logger.Warn(ex, "Couldn't add report to download queue. " + remoteEpisode); + } } } + pending.AddRange(ProcessFailedGrabs(grabbed, failed)); + return new ProcessedDecisions(grabbed, pending, decisions.Where(d => d.Rejected).ToList()); } @@ -90,5 +107,50 @@ namespace NzbDrone.Core.Download //Process both approved and temporarily rejected return decisions.Where(c => (c.Approved || c.TemporarilyRejected) && c.RemoteEpisode.Episodes.Any()).ToList(); } + + private bool IsEpisodeProcessed(List decisions, DownloadDecision report) + { + var episodeIds = report.RemoteEpisode.Episodes.Select(e => e.Id).ToList(); + + return decisions.SelectMany(r => r.RemoteEpisode.Episodes) + .Select(e => e.Id) + .ToList() + .Intersect(episodeIds) + .Any(); + } + + private List ProcessFailedGrabs(List grabbed, List failed) + { + var pending = new List(); + var stored = new List(); + + foreach (var report in failed) + { + // If a release was already grabbed with matching episodes we should store it as a fallback + // and filter it out the next time it is processed incase a higher quality release failed to + // add to the download client, but a lower quality release was sent to another client + // If the release wasn't grabbed already, but was already stored, store it as a fallback, + // otherwise store it as DownloadClientUnavailable. + + if (IsEpisodeProcessed(grabbed, report)) + { + _pendingReleaseService.Add(report, PendingReleaseReason.Fallback); + pending.Add(report); + } + else if (IsEpisodeProcessed(stored, report)) + { + _pendingReleaseService.Add(report, PendingReleaseReason.Fallback); + pending.Add(report); + } + else + { + _pendingReleaseService.Add(report, PendingReleaseReason.DownloadClientUnavailable); + pending.Add(report); + stored.Add(report); + } + } + + return pending; + } } } diff --git a/src/NzbDrone.Core/Download/TorrentClientBase.cs b/src/NzbDrone.Core/Download/TorrentClientBase.cs index b1fcd7e2e..bcfd11f7a 100644 --- a/src/NzbDrone.Core/Download/TorrentClientBase.cs +++ b/src/NzbDrone.Core/Download/TorrentClientBase.cs @@ -133,7 +133,9 @@ namespace NzbDrone.Core.Download var response = _httpClient.Get(request); - if (response.StatusCode == HttpStatusCode.SeeOther || response.StatusCode == HttpStatusCode.Found) + if (response.StatusCode == HttpStatusCode.MovedPermanently || + response.StatusCode == HttpStatusCode.Found || + response.StatusCode == HttpStatusCode.SeeOther) { var locationHeader = response.Headers.GetSingleValue("Location"); diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs index dcaefd073..bf375a797 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs @@ -15,7 +15,8 @@ namespace NzbDrone.Core.Download.TrackedDownloads IHandle, IHandle { - private readonly IProvideDownloadClient _downloadClientProvider; + private readonly IDownloadClientStatusService _downloadClientStatusService; + private readonly IDownloadClientFactory _downloadClientFactory; private readonly IEventAggregator _eventAggregator; private readonly IManageCommandQueue _manageCommandQueue; private readonly IConfigService _configService; @@ -25,16 +26,18 @@ namespace NzbDrone.Core.Download.TrackedDownloads private readonly Logger _logger; private readonly Debouncer _refreshDebounce; - public DownloadMonitoringService(IProvideDownloadClient downloadClientProvider, - IEventAggregator eventAggregator, - IManageCommandQueue manageCommandQueue, - IConfigService configService, - IFailedDownloadService failedDownloadService, - ICompletedDownloadService completedDownloadService, - ITrackedDownloadService trackedDownloadService, - Logger logger) + public DownloadMonitoringService(IDownloadClientStatusService downloadClientStatusService, + IDownloadClientFactory downloadClientFactory, + IEventAggregator eventAggregator, + IManageCommandQueue manageCommandQueue, + IConfigService configService, + IFailedDownloadService failedDownloadService, + ICompletedDownloadService completedDownloadService, + ITrackedDownloadService trackedDownloadService, + Logger logger) { - _downloadClientProvider = downloadClientProvider; + _downloadClientStatusService = downloadClientStatusService; + _downloadClientFactory = downloadClientFactory; _eventAggregator = eventAggregator; _manageCommandQueue = manageCommandQueue; _configService = configService; @@ -56,7 +59,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads _refreshDebounce.Pause(); try { - var downloadClients = _downloadClientProvider.GetDownloadClients(); + var downloadClients = _downloadClientFactory.DownloadHandlingEnabled(); var trackedDownloads = new List(); @@ -64,7 +67,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads { var clientTrackedDownloads = ProcessClientDownloads(downloadClient); - // Only track completed downloads if + // Only track completed downloads if trackedDownloads.AddRange(clientTrackedDownloads.Where(DownloadIsTrackable)); } @@ -84,9 +87,12 @@ namespace NzbDrone.Core.Download.TrackedDownloads try { downloadClientHistory = downloadClient.GetItems().ToList(); + + _downloadClientStatusService.RecordSuccess(downloadClient.Definition.Id); } catch (Exception ex) { + _downloadClientStatusService.RecordFailure(downloadClient.Definition.Id); _logger.Warn(ex, "Unable to retrieve queue and history items from " + downloadClient.Definition.Name); } @@ -107,7 +113,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads private void RemoveCompletedDownloads(List trackedDownloads) { - foreach (var trackedDownload in trackedDownloads.Where(c => !c.DownloadItem.IsReadOnly && c.State == TrackedDownloadStage.Imported)) + foreach (var trackedDownload in trackedDownloads.Where(c => c.DownloadItem.CanBeRemoved && c.State == TrackedDownloadStage.Imported)) { _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); } diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs index 55ce7398d..da92d1628 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs @@ -43,6 +43,8 @@ namespace NzbDrone.Core.Download.TrackedDownloads if (existingItem != null && existingItem.State != TrackedDownloadStage.Downloading) { + LogItemChange(existingItem, existingItem.DownloadItem, downloadItem); + existingItem.DownloadItem = downloadItem; return existingItem; } @@ -87,6 +89,8 @@ namespace NzbDrone.Core.Download.TrackedDownloads if (trackedDownload.RemoteEpisode == null) { + _logger.Trace("No Episode found for download '{0}', not tracking.", trackedDownload.DownloadItem.Title); + return null; } } @@ -96,10 +100,29 @@ namespace NzbDrone.Core.Download.TrackedDownloads return null; } + LogItemChange(trackedDownload, existingItem?.DownloadItem, trackedDownload.DownloadItem); + _cache.Set(trackedDownload.DownloadItem.DownloadId, trackedDownload); return trackedDownload; } + private void LogItemChange(TrackedDownload trackedDownload, DownloadClientItem existingItem, DownloadClientItem downloadItem) + { + if (existingItem == null || + existingItem.Status != downloadItem.Status || + existingItem.CanBeRemoved != downloadItem.CanBeRemoved || + existingItem.CanMoveFiles != downloadItem.CanMoveFiles) + { + _logger.Debug("Tracking '{0}:{1}': ClientState={2}{3} SonarrStage={4} Episode='{5}' OutputPath={6}.", + downloadItem.DownloadClient, downloadItem.Title, + downloadItem.Status, downloadItem.CanBeRemoved ? "" : + downloadItem.CanMoveFiles ? " (busy)" : " (readonly)", + trackedDownload.State, + trackedDownload.RemoteEpisode?.ParsedEpisodeInfo, + downloadItem.OutputPath); + } + } + private static TrackedDownloadStage GetStateFromHistory(HistoryEventType eventType) { switch (eventType) diff --git a/src/NzbDrone.Core/Extras/ExistingExtraFileService.cs b/src/NzbDrone.Core/Extras/ExistingExtraFileService.cs index f2646d67e..507b63b62 100644 --- a/src/NzbDrone.Core/Extras/ExistingExtraFileService.cs +++ b/src/NzbDrone.Core/Extras/ExistingExtraFileService.cs @@ -41,7 +41,7 @@ namespace NzbDrone.Core.Extras _logger.Debug("Looking for existing extra files in {0}", series.Path); var filesOnDisk = _diskScanService.GetNonVideoFiles(series.Path); - var possibleExtraFiles = _diskScanService.FilterFiles(series, filesOnDisk); + var possibleExtraFiles = _diskScanService.FilterFiles(series.Path, filesOnDisk); var filteredFiles = possibleExtraFiles; var importedFiles = new List(); diff --git a/src/NzbDrone.Core/Extras/ExtraService.cs b/src/NzbDrone.Core/Extras/ExtraService.cs index 811d3ebea..99d9ec3a6 100644 --- a/src/NzbDrone.Core/Extras/ExtraService.cs +++ b/src/NzbDrone.Core/Extras/ExtraService.cs @@ -71,7 +71,7 @@ namespace NzbDrone.Core.Extras .Select(e => e.Trim(' ', '.')) .ToList(); - var matchingFilenames = files.Where(f => Path.GetFileNameWithoutExtension(f).StartsWith(sourceFileName)); + var matchingFilenames = files.Where(f => Path.GetFileNameWithoutExtension(f).StartsWith(sourceFileName, StringComparison.InvariantCultureIgnoreCase)); foreach (var matchingFilename in matchingFilenames) { diff --git a/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs b/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs index f21e989aa..6d4450f72 100644 --- a/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs +++ b/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs @@ -90,7 +90,6 @@ namespace NzbDrone.Core.Extras.Files filenameBuilder.Append(fileNameSuffix); } - filenameBuilder.Append("."); filenameBuilder.Append(extraFile.Extension); var existingFileName = Path.Combine(series.Path, extraFile.RelativePath); diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadata.cs index cf5d5e61d..23ea969cd 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadata.cs @@ -180,6 +180,11 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox public override List SeriesImages(Series series) { + if (!Settings.SeriesImages) + { + return new List(); + } + var image = series.Images.SingleOrDefault(c => c.CoverType == MediaCoverTypes.Poster) ?? series.Images.FirstOrDefault(); if (image == null) { @@ -195,6 +200,11 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox public override List SeasonImages(Series series, Season season) { + if (!Settings.SeasonImages) + { + return new List(); + } + var seasonFolders = GetSeasonFolders(series); string seasonFolder; @@ -220,6 +230,11 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox public override List EpisodeImages(Series series, EpisodeFile episodeFile) { + if (!Settings.EpisodeImages) + { + return new List(); + } + var screenshot = episodeFile.Episodes.Value.First().Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot); if (screenshot == null) diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs index 99e384cb9..6cfd5f2d0 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs @@ -11,6 +11,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Extras.Metadata.Files; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc @@ -103,7 +104,7 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc return metadata; } - if (filename.Equals("tvshow.nfo", StringComparison.InvariantCultureIgnoreCase)) + if (filename.Equals("tvshow.nfo", StringComparison.OrdinalIgnoreCase)) { metadata.Type = MetadataType.SeriesMetadata; return metadata; @@ -113,7 +114,7 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc if (parseResult != null && !parseResult.FullSeason && - Path.GetExtension(filename) == ".nfo") + Path.GetExtension(filename).Equals(".nfo", StringComparison.OrdinalIgnoreCase)) { metadata.Type = MetadataType.EpisodeMetadata; return metadata; @@ -241,17 +242,19 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc if (episodeFile.MediaInfo != null) { + var sceneName = episodeFile.GetSceneOrFileName(); + var fileInfo = new XElement("fileinfo"); var streamDetails = new XElement("streamdetails"); var video = new XElement("video"); video.Add(new XElement("aspect", (float)episodeFile.MediaInfo.Width / (float)episodeFile.MediaInfo.Height)); video.Add(new XElement("bitrate", episodeFile.MediaInfo.VideoBitrate)); - video.Add(new XElement("codec", episodeFile.MediaInfo.VideoCodec)); + video.Add(new XElement("codec", MediaInfoFormatter.FormatVideoCodec(episodeFile.MediaInfo, sceneName))); video.Add(new XElement("framerate", episodeFile.MediaInfo.VideoFps)); video.Add(new XElement("height", episodeFile.MediaInfo.Height)); video.Add(new XElement("scantype", episodeFile.MediaInfo.ScanType)); - video.Add(new XElement("width", episodeFile.MediaInfo.Height)); + video.Add(new XElement("width", episodeFile.MediaInfo.Width)); if (episodeFile.MediaInfo.RunTime != null) { @@ -264,11 +267,11 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc var audio = new XElement("audio"); audio.Add(new XElement("bitrate", episodeFile.MediaInfo.AudioBitrate)); audio.Add(new XElement("channels", episodeFile.MediaInfo.AudioChannels)); - audio.Add(new XElement("codec", GetAudioCodec(episodeFile.MediaInfo.AudioFormat))); + audio.Add(new XElement("codec", MediaInfoFormatter.FormatAudioCodec(episodeFile.MediaInfo, sceneName))); audio.Add(new XElement("language", episodeFile.MediaInfo.AudioLanguages)); streamDetails.Add(audio); - if (episodeFile.MediaInfo.Subtitles != null && episodeFile.MediaInfo.Subtitles.Length > 0) + if (episodeFile.MediaInfo.Subtitles.IsNotNullOrWhiteSpace()) { var subtitle = new XElement("subtitle"); subtitle.Add(new XElement("language", episodeFile.MediaInfo.Subtitles)); @@ -379,15 +382,5 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc { return Path.ChangeExtension(episodeFilePath, "").Trim('.') + "-thumb.jpg"; } - - private string GetAudioCodec(string audioCodec) - { - if (audioCodec == "AC-3") - { - return "AC3"; - } - - return audioCodec; - } } } diff --git a/src/NzbDrone.Core/Extras/Others/OtherExtraService.cs b/src/NzbDrone.Core/Extras/Others/OtherExtraService.cs index f3e331c30..18370b0fe 100644 --- a/src/NzbDrone.Core/Extras/Others/OtherExtraService.cs +++ b/src/NzbDrone.Core/Extras/Others/OtherExtraService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using NLog; @@ -65,7 +66,7 @@ namespace NzbDrone.Core.Extras.Others public override ExtraFile Import(Series series, EpisodeFile episodeFile, string path, string extension, bool readOnly) { // If the extension is .nfo we need to change it to .nfo-orig - if (Path.GetExtension(path).Equals(".nfo")) + if (Path.GetExtension(path).Equals(".nfo", StringComparison.OrdinalIgnoreCase)) { extension += "-orig"; } diff --git a/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileExtensions.cs b/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileExtensions.cs index 423d14656..f52b0f162 100644 --- a/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileExtensions.cs +++ b/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileExtensions.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace NzbDrone.Core.Extras.Subtitles { @@ -8,7 +9,7 @@ namespace NzbDrone.Core.Extras.Subtitles static SubtitleFileExtensions() { - _fileExtensions = new HashSet + _fileExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".aqt", ".ass", diff --git a/src/NzbDrone.Core/HealthCheck/CheckOnAttribute.cs b/src/NzbDrone.Core/HealthCheck/CheckOnAttribute.cs new file mode 100644 index 000000000..ee427cb38 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/CheckOnAttribute.cs @@ -0,0 +1,16 @@ +using System; +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.HealthCheck +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public class CheckOnAttribute: Attribute + { + public Type EventType { get; set; } + + public CheckOnAttribute(Type eventType) + { + EventType = eventType; + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/AppDataLocationCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/AppDataLocationCheck.cs index ad4f2db9e..bdd994a02 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/AppDataLocationCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/AppDataLocationCheck.cs @@ -1,5 +1,6 @@ using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration.Events; namespace NzbDrone.Core.HealthCheck.Checks { @@ -11,7 +12,7 @@ namespace NzbDrone.Core.HealthCheck.Checks { _appFolderInfo = appFolderInfo; } - + public override HealthCheck Check() { if (_appFolderInfo.StartUpFolder.IsParentPath(_appFolderInfo.AppDataFolder) || @@ -22,7 +23,5 @@ namespace NzbDrone.Core.HealthCheck.Checks return new HealthCheck(GetType()); } - - public override bool CheckOnConfigChange => false; } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/DeprecatedDroneFactoryCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/DeprecatedDroneFactoryCheck.cs new file mode 100644 index 000000000..2278cdc53 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/DeprecatedDroneFactoryCheck.cs @@ -0,0 +1,30 @@ +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Configuration.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ConfigSavedEvent))] + public class DeprecatedDroneFactoryCheck : HealthCheckBase + { + private readonly IConfigService _configService; + + public DeprecatedDroneFactoryCheck(IConfigService configService) + { + _configService = configService; + } + + public override HealthCheck Check() + { + var droneFactoryFolder = _configService.DownloadedEpisodesFolder; + + if (droneFactoryFolder.IsNullOrWhiteSpace()) + { + return new HealthCheck(GetType()); + } + + return new HealthCheck(GetType(), HealthCheckResult.Warning, "Drone Factory is deprecated and should not be used", "#drone-factory-is-deprecated"); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientCheck.cs index d99eed1a3..8b1959c65 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientCheck.cs @@ -2,9 +2,12 @@ using System.Linq; using NLog; using NzbDrone.Core.Download; +using NzbDrone.Core.ThingiProvider.Events; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] public class DownloadClientCheck : HealthCheckBase { private readonly IProvideDownloadClient _downloadClientProvider; @@ -33,11 +36,10 @@ namespace NzbDrone.Core.HealthCheck.Checks } catch (Exception ex) { - - _logger.Error(ex, "Unable to communicate with {0}", downloadClient.Definition.Name); + _logger.Debug(ex, "Unable to communicate with {0}", downloadClient.Definition.Name); var message = $"Unable to communicate with {downloadClient.Definition.Name}."; - return new HealthCheck(GetType(), HealthCheckResult.Error, $"{message} {ex.Message}"); + return new HealthCheck(GetType(), HealthCheckResult.Error, $"{message} {ex.Message}", "#unable-to-communicate-with-download-client"); } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientStatusCheck.cs new file mode 100644 index 000000000..330feacac --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientStatusCheck.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Download; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderStatusChangedEvent))] + public class DownloadClientStatusCheck : HealthCheckBase + { + private readonly IDownloadClientFactory _providerFactory; + private readonly IDownloadClientStatusService _providerStatusService; + + public DownloadClientStatusCheck(IDownloadClientFactory providerFactory, IDownloadClientStatusService providerStatusService) + { + _providerFactory = providerFactory; + _providerStatusService = providerStatusService; + } + + public override HealthCheck Check() + { + var enabledProviders = _providerFactory.GetAvailableProviders(); + var backOffProviders = enabledProviders.Join(_providerStatusService.GetBlockedProviders(), + i => i.Definition.Id, + s => s.ProviderId, + (i, s) => new { Provider = i, Status = s }) + .ToList(); + + if (backOffProviders.Empty()) + { + return new HealthCheck(GetType()); + } + + if (backOffProviders.Count == enabledProviders.Count) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, "All download clients are unavailable due to failures", "#download-clients-are-unavailable-due-to-failures"); + } + + return new HealthCheck(GetType(), HealthCheckResult.Warning, string.Format("Download clients unavailable due to failures: {0}", string.Join(", ", backOffProviders.Select(v => v.Provider.Definition.Name))), "#download-clients-are-unavailable-due-to-failures"); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/DroneFactoryCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/DroneFactoryCheck.cs index ffae0b4f6..2e7e51135 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/DroneFactoryCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/DroneFactoryCheck.cs @@ -1,9 +1,11 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Configuration.Events; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(ConfigSavedEvent))] public class DroneFactoryCheck : HealthCheckBase { private readonly IConfigService _configService; @@ -28,7 +30,7 @@ namespace NzbDrone.Core.HealthCheck.Checks { return new HealthCheck(GetType(), HealthCheckResult.Error, "Drone factory folder does not exist"); } - + if (!_diskProvider.FolderWritable(droneFactoryFolder)) { return new HealthCheck(GetType(), HealthCheckResult.Error, "Unable to write to drone factory folder"); diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs index ec35653c7..0ef0a3d0b 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs @@ -3,13 +3,18 @@ using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Disk; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Download.Clients.Nzbget; using NzbDrone.Core.Download.Clients.Sabnzbd; +using NzbDrone.Core.ThingiProvider.Events; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ConfigSavedEvent))] public class ImportMechanismCheck : HealthCheckBase { private readonly IConfigService _configService; @@ -35,7 +40,7 @@ namespace NzbDrone.Core.HealthCheck.Checks Status = v.GetStatus() }).ToList(); } - catch (DownloadClientException) + catch (Exception) { // One or more download clients failed, assume the health is okay and verify later return new HealthCheck(GetType()); @@ -91,6 +96,6 @@ namespace NzbDrone.Core.HealthCheck.Checks public class ImportMechanismCheckStatus { public IDownloadClient DownloadClient { get; set; } - public DownloadClientStatus Status { get; set; } + public DownloadClientInfo Status { get; set; } } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerRssCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerRssCheck.cs index e0c9c9e5c..65d8f0f24 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/IndexerRssCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerRssCheck.cs @@ -1,9 +1,13 @@ using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Indexers; +using NzbDrone.Core.ThingiProvider.Events; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderStatusChangedEvent))] public class IndexerRssCheck : HealthCheckBase { private readonly IIndexerFactory _indexerFactory; diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerSearchCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerSearchCheck.cs index b2b81e4ec..a4c33506c 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/IndexerSearchCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerSearchCheck.cs @@ -1,9 +1,13 @@ using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Indexers; +using NzbDrone.Core.ThingiProvider.Events; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderStatusChangedEvent))] public class IndexerSearchCheck : HealthCheckBase { private readonly IIndexerFactory _indexerFactory; diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs index 29eadb180..5c5f89283 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs @@ -2,41 +2,44 @@ using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Indexers; +using NzbDrone.Core.ThingiProvider.Events; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderStatusChangedEvent))] public class IndexerStatusCheck : HealthCheckBase { - private readonly IIndexerFactory _indexerFactory; - private readonly IIndexerStatusService _indexerStatusService; + private readonly IIndexerFactory _providerFactory; + private readonly IIndexerStatusService _providerStatusService; - public IndexerStatusCheck(IIndexerFactory indexerFactory, IIndexerStatusService indexerStatusService) + public IndexerStatusCheck(IIndexerFactory providerFactory, IIndexerStatusService providerStatusService) { - _indexerFactory = indexerFactory; - _indexerStatusService = indexerStatusService; + _providerFactory = providerFactory; + _providerStatusService = providerStatusService; } public override HealthCheck Check() { - var enabledIndexers = _indexerFactory.GetAvailableProviders(); - var backOffIndexers = enabledIndexers.Join(_indexerStatusService.GetBlockedIndexers(), + var enabledProviders = _providerFactory.GetAvailableProviders(); + var backOffProviders = enabledProviders.Join(_providerStatusService.GetBlockedProviders(), i => i.Definition.Id, - s => s.IndexerId, - (i, s) => new { Indexer = i, Status = s }) - .Where(v => (v.Status.MostRecentFailure - v.Status.InitialFailure) > TimeSpan.FromHours(1)) + s => s.ProviderId, + (i, s) => new { Provider = i, Status = s }) .ToList(); - if (backOffIndexers.Empty()) + if (backOffProviders.Empty()) { return new HealthCheck(GetType()); } - if (backOffIndexers.Count == enabledIndexers.Count) + if (backOffProviders.Count == enabledProviders.Count) { return new HealthCheck(GetType(), HealthCheckResult.Error, "All indexers are unavailable due to failures", "#indexers-are-unavailable-due-to-failures"); } - return new HealthCheck(GetType(), HealthCheckResult.Warning, string.Format("Indexers unavailable due to failures: {0}", string.Join(", ", backOffIndexers.Select(v => v.Indexer.Definition.Name))), "#indexers-are-unavailable-due-to-failures"); + return new HealthCheck(GetType(), HealthCheckResult.Warning, string.Format("Indexers unavailable due to failures: {0}", string.Join(", ", backOffProviders.Select(v => v.Provider.Definition.Name))), "#indexers-are-unavailable-due-to-failures"); } } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/MediaInfoDllCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/MediaInfoDllCheck.cs index 5b5a9f3f4..b8ca565c5 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/MediaInfoDllCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/MediaInfoDllCheck.cs @@ -20,7 +20,5 @@ namespace NzbDrone.Core.HealthCheck.Checks return new HealthCheck(GetType()); } - - public override bool CheckOnConfigChange => false; } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/MonoTlsCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/MonoTlsCheck.cs new file mode 100644 index 000000000..a3e6c67d3 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/MonoTlsCheck.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; +using System.Reflection; +using NLog; +using NzbDrone.Common.EnvironmentInfo; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + public class MonoTlsCheck : HealthCheckBase + { + private readonly IPlatformInfo _platformInfo; + private readonly Logger _logger; + + public MonoTlsCheck(IPlatformInfo platformInfo, Logger logger) + { + _platformInfo = platformInfo; + _logger = logger; + } + + public override HealthCheck Check() + { + if (!PlatformInfo.IsMono) + { + return new HealthCheck(GetType()); + } + + var monoVersion = _platformInfo.Version; + + if (monoVersion >= new Version("5.0.0") && Environment.GetEnvironmentVariable("MONO_TLS_PROVIDER") == "legacy") + { + // Mono 5.0 still has issues in combination with libmediainfo, so disabling this check for now. + //_logger.Debug("Mono version 5.0.0 or higher and legacy TLS provider is selected, recommending user to switch to btls."); + //return new HealthCheck(GetType(), HealthCheckResult.Warning, "Sonarr now supports Mono 5.x with btls enabled, consider removing MONO_TLS_PROVIDER=legacy option"); + } + + return new HealthCheck(GetType()); + } + + public override bool CheckOnSchedule => false; + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/MonoVersionCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/MonoVersionCheck.cs index 2033b9d87..639686cc7 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/MonoVersionCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/MonoVersionCheck.cs @@ -47,8 +47,6 @@ namespace NzbDrone.Core.HealthCheck.Checks return new HealthCheck(GetType(), HealthCheckResult.Warning, "You are running an old and unsupported version of Mono. Please upgrade Mono for improved stability."); } - public override bool CheckOnConfigChange => false; - public override bool CheckOnSchedule => false; private bool HasMonoBug18599() @@ -80,4 +78,4 @@ namespace NzbDrone.Core.HealthCheck.Checks return false; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/MountCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/MountCheck.cs new file mode 100644 index 000000000..6e79be2d6 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/MountCheck.cs @@ -0,0 +1,36 @@ +using System.Linq; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + public class MountCheck : HealthCheckBase + { + private readonly IDiskProvider _diskProvider; + private readonly ISeriesService _seriesService; + + public MountCheck(IDiskProvider diskProvider, ISeriesService seriesService) + { + _diskProvider = diskProvider; + _seriesService = seriesService; + } + + public override HealthCheck Check() + { + // Not best for optimization but due to possible symlinks and junctions, we get mounts based on series path so internals can handle mount resolution. + var mounts = _seriesService.GetAllSeries() + .Select(series => _diskProvider.GetMount(series.Path)) + .Where(m => m != null && m.MountOptions != null && m.MountOptions.IsReadOnly) + .DistinctBy(m => m.RootDirectory) + .ToList(); + + if (mounts.Any()) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, "Mount containing a series path is mounted read-only: " + string.Join(",", mounts.Select(m => m.Name)), "#series-mount-ro"); + } + + return new HealthCheck(GetType()); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs index d9c4d700c..ea742bc6c 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs @@ -5,9 +5,11 @@ using System; using System.Linq; using System.Net; using NzbDrone.Common.Cloud; +using NzbDrone.Core.Configuration.Events; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(ConfigSavedEvent))] public class ProxyCheck : HealthCheckBase { private readonly Logger _logger; @@ -43,7 +45,7 @@ namespace NzbDrone.Core.HealthCheck.Checks { var response = _client.Execute(request); - // We only care about 400 responses, other error codes can be ignored + // We only care about 400 responses, other error codes can be ignored if (response.StatusCode == HttpStatusCode.BadRequest) { _logger.Error("Proxy Health Check failed: {0}", response.StatusCode); diff --git a/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs index d7cb3f7d1..ae3064425 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs @@ -1,9 +1,12 @@ using System.Linq; using NzbDrone.Common.Disk; using NzbDrone.Core.Tv; +using NzbDrone.Core.Tv.Events; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(SeriesDeletedEvent))] + [CheckOn(typeof(SeriesMovedEvent))] public class RootFolderCheck : HealthCheckBase { private readonly ISeriesService _seriesService; @@ -36,7 +39,5 @@ namespace NzbDrone.Core.HealthCheck.Checks return new HealthCheck(GetType()); } - - public override bool CheckOnConfigChange => false; } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs index c0d7a5c31..5b8c2f3a5 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs @@ -4,10 +4,12 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.Update; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(ConfigFileSavedEvent))] public class UpdateCheck : HealthCheckBase { private readonly IDiskProvider _diskProvider; @@ -66,7 +68,5 @@ namespace NzbDrone.Core.HealthCheck.Checks return new HealthCheck(GetType()); } - - public override bool CheckOnConfigChange => false; } } diff --git a/src/NzbDrone.Core/HealthCheck/HealthCheckBase.cs b/src/NzbDrone.Core/HealthCheck/HealthCheckBase.cs index 5e1700ac6..0d05d0454 100644 --- a/src/NzbDrone.Core/HealthCheck/HealthCheckBase.cs +++ b/src/NzbDrone.Core/HealthCheck/HealthCheckBase.cs @@ -6,8 +6,6 @@ public virtual bool CheckOnStartup => true; - public virtual bool CheckOnConfigChange => true; - public virtual bool CheckOnSchedule => true; } } diff --git a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs index 56789b8a1..3ddd11d8a 100644 --- a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs +++ b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs @@ -3,6 +3,9 @@ using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Messaging; +using NzbDrone.Common.Reflection; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.Download; using NzbDrone.Core.Indexers; @@ -21,13 +24,12 @@ namespace NzbDrone.Core.HealthCheck public class HealthCheckService : IHealthCheckService, IExecute, IHandleAsync, - IHandleAsync, - IHandleAsync>, - IHandleAsync>, - IHandleAsync>, - IHandleAsync> + IHandleAsync { - private readonly IEnumerable _healthChecks; + private readonly IProvideHealthCheck[] _healthChecks; + private readonly IProvideHealthCheck[] _startupHealthChecks; + private readonly IProvideHealthCheck[] _scheduledHealthChecks; + private readonly Dictionary _eventDrivenHealthChecks; private readonly IEventAggregator _eventAggregator; private readonly ICacheManager _cacheManager; private readonly Logger _logger; @@ -39,12 +41,16 @@ namespace NzbDrone.Core.HealthCheck ICacheManager cacheManager, Logger logger) { - _healthChecks = healthChecks; + _healthChecks = healthChecks.ToArray(); _eventAggregator = eventAggregator; _cacheManager = cacheManager; _logger = logger; _healthCheckResults = _cacheManager.GetCache(GetType()); + + _startupHealthChecks = _healthChecks.Where(v => v.CheckOnStartup).ToArray(); + _scheduledHealthChecks = _healthChecks.Where(v => v.CheckOnSchedule).ToArray(); + _eventDrivenHealthChecks = GetEventDrivenHealthChecks(); } public List Results() @@ -52,10 +58,17 @@ namespace NzbDrone.Core.HealthCheck return _healthCheckResults.Values.ToList(); } - private void PerformHealthCheck(Func predicate) + private Dictionary GetEventDrivenHealthChecks() { - var results = _healthChecks.Where(predicate) - .Select(c => c.Check()) + return _healthChecks + .SelectMany(h => h.GetType().GetAttributes().Select(a => Tuple.Create(a.EventType, h))) + .GroupBy(t => t.Item1, t => t.Item2) + .ToDictionary(g => g.Key, g => g.ToArray()); + } + + private void PerformHealthCheck(IProvideHealthCheck[] healthChecks) + { + var results = healthChecks.Select(c => c.Check()) .ToList(); foreach (var result in results) @@ -76,37 +89,37 @@ namespace NzbDrone.Core.HealthCheck public void Execute(CheckHealthCommand message) { - PerformHealthCheck(c => message.Trigger == CommandTrigger.Manual || c.CheckOnSchedule); + if (message.Trigger == CommandTrigger.Manual) + { + PerformHealthCheck(_healthChecks); + } + else + { + PerformHealthCheck(_scheduledHealthChecks); + } } public void HandleAsync(ApplicationStartedEvent message) { - PerformHealthCheck(c => c.CheckOnStartup); + PerformHealthCheck(_startupHealthChecks); } - public void HandleAsync(ConfigSavedEvent message) + public void HandleAsync(IEvent message) { - PerformHealthCheck(c => c.CheckOnConfigChange); - } + if (message is HealthCheckCompleteEvent) + { + return; + } - public void HandleAsync(ProviderUpdatedEvent message) - { - PerformHealthCheck(c => c.CheckOnConfigChange); - } + IProvideHealthCheck[] checks; + if (!_eventDrivenHealthChecks.TryGetValue(message.GetType(), out checks)) + { + return; + } - public void HandleAsync(ProviderDeletedEvent message) - { - PerformHealthCheck(c => c.CheckOnConfigChange); - } + // TODO: Add debounce - public void HandleAsync(ProviderUpdatedEvent message) - { - PerformHealthCheck(c => c.CheckOnConfigChange); - } - - public void HandleAsync(ProviderDeletedEvent message) - { - PerformHealthCheck(c => c.CheckOnConfigChange); + PerformHealthCheck(checks); } } } diff --git a/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs b/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs index ece0b7952..7cffd0e1e 100644 --- a/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs @@ -4,7 +4,6 @@ { HealthCheck Check(); bool CheckOnStartup { get; } - bool CheckOnConfigChange { get; } bool CheckOnSchedule { get; } } } diff --git a/src/NzbDrone.Core/History/History.cs b/src/NzbDrone.Core/History/History.cs index be35637c8..e6c2fe223 100644 --- a/src/NzbDrone.Core/History/History.cs +++ b/src/NzbDrone.Core/History/History.cs @@ -36,6 +36,7 @@ namespace NzbDrone.Core.History SeriesFolderImported = 2, DownloadFolderImported = 3, DownloadFailed = 4, - EpisodeFileDeleted = 5 + EpisodeFileDeleted = 5, + EpisodeFileRenamed = 6 } } \ No newline at end of file diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index 32815beef..38450727a 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -32,6 +32,7 @@ namespace NzbDrone.Core.History IHandle, IHandle, IHandle, + IHandle, IHandle { private readonly IHistoryRepository _historyRepository; @@ -257,6 +258,34 @@ namespace NzbDrone.Core.History } } + public void Handle(EpisodeFileRenamedEvent message) + { + var sourcePath = message.OriginalPath; + var sourceRelativePath = message.Series.Path.GetRelativePath(message.OriginalPath); + var path = Path.Combine(message.Series.Path, message.EpisodeFile.RelativePath); + var relativePath = message.EpisodeFile.RelativePath; + + foreach (var episode in message.EpisodeFile.Episodes.Value) + { + var history = new History + { + EventType = HistoryEventType.EpisodeFileRenamed, + Date = DateTime.UtcNow, + Quality = message.EpisodeFile.Quality, + SourceTitle = message.OriginalPath, + SeriesId = message.EpisodeFile.SeriesId, + EpisodeId = episode.Id, + }; + + history.Data.Add("SourcePath", sourcePath); + history.Data.Add("SourceRelativePath", sourceRelativePath); + history.Data.Add("Path", path); + history.Data.Add("RelativePath", relativePath); + + _historyRepository.Insert(history); + } + } + public void Handle(SeriesDeletedEvent message) { _historyRepository.DeleteForSeries(message.Series.Id); diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupDownloadClientUnavailablePendingReleases.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupDownloadClientUnavailablePendingReleases.cs new file mode 100644 index 000000000..51c3ba3f9 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupDownloadClientUnavailablePendingReleases.cs @@ -0,0 +1,32 @@ +using System; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Download.Pending; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupDownloadClientUnavailablePendingReleases : IHousekeepingTask + { + private readonly IMainDatabase _database; + + public CleanupDownloadClientUnavailablePendingReleases(IMainDatabase database) + { + _database = database; + } + + public void Clean() + { + var mapper = _database.GetDataMapper(); + var twoWeeksAgo = DateTime.UtcNow.AddDays(-14); + + mapper.Delete(p => p.Added < twoWeeksAgo && + (p.Reason == PendingReleaseReason.DownloadClientUnavailable || + p.Reason == PendingReleaseReason.Fallback)); + +// mapper.AddParameter("twoWeeksAgo", $"{DateTime.UtcNow.AddDays(-14).ToString("s")}Z"); + +// mapper.ExecuteNonQuery(@"DELETE FROM PendingReleases +// WHERE Added < @twoWeeksAgo +// AND (Reason = 'DownloadClientUnavailable' OR Reason = 'Fallback')"); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs new file mode 100644 index 000000000..3bb631eb9 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedDownloadClientStatus : IHousekeepingTask + { + private readonly IMainDatabase _database; + + public CleanupOrphanedDownloadClientStatus(IMainDatabase database) + { + _database = database; + } + + public void Clean() + { + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM DownloadClientStatus + WHERE Id IN ( + SELECT DownloadClientStatus.Id FROM DownloadClientStatus + LEFT OUTER JOIN DownloadClients + ON DownloadClientStatus.ProviderId = DownloadClients.Id + WHERE DownloadClients.Id IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs index b3cf47027..056c6a9c3 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers WHERE Id IN ( SELECT IndexerStatus.Id FROM IndexerStatus LEFT OUTER JOIN Indexers - ON IndexerStatus.IndexerId = Indexers.Id + ON IndexerStatus.ProviderId = Indexers.Id WHERE Indexers.Id IS NULL)"); } } diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs index c5e602e59..066a4bd7e 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions public virtual bool MonitoredEpisodesOnly { get; set; } public virtual bool UserInvokedSearch { get; set; } - public List QueryTitles => SceneTitles.Select(GetQueryTitle).ToList(); + public List QueryTitles => SceneTitles.Select(GetQueryTitle).Distinct().ToList(); public static string GetQueryTitle(string title) { @@ -37,4 +37,4 @@ namespace NzbDrone.Core.IndexerSearch.Definitions return cleanTitle.Trim('+', ' '); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs index 7c5eb1060..6c8c5b740 100644 --- a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Threading.Tasks; @@ -199,7 +199,7 @@ namespace NzbDrone.Core.IndexerSearch } else { - throw new ArgumentOutOfRangeException("AbsoluteEpisodeNumber", "Can not search for an episode without an absolute episode number"); + throw new ArgumentOutOfRangeException("AbsoluteEpisodeNumber", $"Can not search for {series.Title} - S{episode.SeasonNumber:00}E{episode.EpisodeNumber:00} it does not have an absolute episode number"); } return Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec); @@ -237,9 +237,12 @@ namespace NzbDrone.Core.IndexerSearch episodes.Select(e => e.SeasonNumber).Distinct().ToList(), episodes.Select(e => e.SceneSeasonNumber ?? e.SeasonNumber).Distinct().ToList()); - spec.Episodes = episodes; + if (!spec.SceneTitles.Contains(series.Title)) + { + spec.SceneTitles.Add(series.Title); + } - spec.SceneTitles.Add(series.Title); + spec.Episodes = episodes; spec.UserInvokedSearch = userInvokedSearch; return spec; diff --git a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs index 6e48f46de..a858192d5 100644 --- a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs +++ b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs @@ -1,7 +1,6 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using FluentValidation; using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.BitMeTv @@ -23,13 +22,14 @@ namespace NzbDrone.Core.Indexers.BitMeTv } } - public class BitMeTvSettings : IProviderConfig + public class BitMeTvSettings : ITorrentIndexerSettings { private static readonly BitMeTvSettingsValidator Validator = new BitMeTvSettingsValidator(); public BitMeTvSettings() { BaseUrl = "https://www.bitmetv.org"; + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; } [FieldDefinition(0, Label = "Website URL")] @@ -44,9 +44,12 @@ namespace NzbDrone.Core.Indexers.BitMeTv [FieldDefinition(3, Label = "Cookie", HelpText = "BitMeTv uses a login cookie needed to access the rss, you'll have to retrieve it via a browser.")] public string Cookie { get; set; } + [FieldDefinition(4, Type = FieldType.Textbox, Label = "Minimum Seeders", HelpText = "Minimum number of seeders required.", Advanced = true)] + public int MinimumSeeders { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetRequestGenerator.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetRequestGenerator.cs index b5a39a94c..dc8a0d6e0 100644 --- a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetRequestGenerator.cs @@ -30,7 +30,7 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet Id = ">=" + (LastRecentTorrentID.Value - 100) })); } - + pageableRequests.AddTier(GetPagedRequests(MaxPages, new BroadcastheNetTorrentQuery() { Age = "<=86400" @@ -80,7 +80,7 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet foreach (var seasonNumber in searchCriteria.Episodes.Select(v => v.SeasonNumber).Distinct()) { parameters.Category = "Season"; - parameters.Name = string.Format("Season {0}", seasonNumber); + parameters.Name = string.Format("Season {0}%", seasonNumber); pageableRequests.Add(GetPagedRequests(MaxPages, parameters)); @@ -146,7 +146,7 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet parameters = parameters.Clone(); parameters.Category = "Season"; - parameters.Name = string.Format("Season {0}", seasonNumber); + parameters.Name = string.Format("Season {0}%", seasonNumber); pageableRequests.Add(GetPagedRequests(MaxPages, parameters)); } diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetSettings.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetSettings.cs index 620ce9887..3890fef1f 100644 --- a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetSettings.cs +++ b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetSettings.cs @@ -1,6 +1,5 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.BroadcastheNet @@ -14,13 +13,14 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet } } - public class BroadcastheNetSettings : IProviderConfig + public class BroadcastheNetSettings : ITorrentIndexerSettings { private static readonly BroadcastheNetSettingsValidator Validator = new BroadcastheNetSettingsValidator(); public BroadcastheNetSettings() { BaseUrl = "http://api.broadcasthe.net/"; + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; } [FieldDefinition(0, Label = "API URL", Advanced = true, HelpText = "Do not change this unless you know what you're doing. Since your API key will be sent to that host.")] @@ -29,6 +29,9 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet [FieldDefinition(1, Label = "API Key")] public string ApiKey { get; set; } + [FieldDefinition(2, Type = FieldType.Textbox, Label = "Minimum Seeders", HelpText = "Minimum number of seeders required.", Advanced = true)] + public int MinimumSeeders { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Indexers/Fanzub/FanzubSettings.cs b/src/NzbDrone.Core/Indexers/Fanzub/FanzubSettings.cs index 1f9f25028..fd07365fd 100644 --- a/src/NzbDrone.Core/Indexers/Fanzub/FanzubSettings.cs +++ b/src/NzbDrone.Core/Indexers/Fanzub/FanzubSettings.cs @@ -13,7 +13,7 @@ namespace NzbDrone.Core.Indexers.Fanzub } } - public class FanzubSettings : IProviderConfig + public class FanzubSettings : IIndexerSettings { private static readonly FanzubSettingsValidator Validator = new FanzubSettingsValidator(); diff --git a/src/NzbDrone.Core/Indexers/HDBits/HDBitsSettings.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBitsSettings.cs index 933a134d2..fb2890c9c 100644 --- a/src/NzbDrone.Core/Indexers/HDBits/HDBitsSettings.cs +++ b/src/NzbDrone.Core/Indexers/HDBits/HDBitsSettings.cs @@ -1,6 +1,5 @@ using FluentValidation; using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.HDBits @@ -14,13 +13,14 @@ namespace NzbDrone.Core.Indexers.HDBits } } - public class HDBitsSettings : IProviderConfig + public class HDBitsSettings : ITorrentIndexerSettings { private static readonly HDBitsSettingsValidator Validator = new HDBitsSettingsValidator(); public HDBitsSettings() { BaseUrl = "https://hdbits.org"; + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; } [FieldDefinition(0, Label = "Username")] @@ -32,6 +32,9 @@ namespace NzbDrone.Core.Indexers.HDBits [FieldDefinition(2, Label = "API URL", Advanced = true, HelpText = "Do not change this unless you know what you're doing. Since your API key will be sent to that host.")] public string BaseUrl { get; set; } + [FieldDefinition(3, Type = FieldType.Textbox, Label = "Minimum Seeders", HelpText = "Minimum number of seeders required.", Advanced = true)] + public int MinimumSeeders { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs index b88158b14..481d55056 100644 --- a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs @@ -17,7 +17,7 @@ using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Indexers { public abstract class HttpIndexerBase : IndexerBase - where TSettings : IProviderConfig, new() + where TSettings : IIndexerSettings, new() { protected const int MaxNumResultsPerQuery = 1000; @@ -46,9 +46,7 @@ namespace NzbDrone.Core.Indexers return new List(); } - var generator = GetRequestGenerator(); - - return FetchReleases(generator.GetRecentRequests(), true); + return FetchReleases(g => g.GetRecentRequests(), true); } public override IList Fetch(SingleEpisodeSearchCriteria searchCriteria) @@ -58,9 +56,7 @@ namespace NzbDrone.Core.Indexers return new List(); } - var generator = GetRequestGenerator(); - - return FetchReleases(generator.GetSearchRequests(searchCriteria)); + return FetchReleases(g => g.GetSearchRequests(searchCriteria)); } public override IList Fetch(SeasonSearchCriteria searchCriteria) @@ -70,9 +66,7 @@ namespace NzbDrone.Core.Indexers return new List(); } - var generator = GetRequestGenerator(); - - return FetchReleases(generator.GetSearchRequests(searchCriteria)); + return FetchReleases(g => g.GetSearchRequests(searchCriteria)); } public override IList Fetch(DailyEpisodeSearchCriteria searchCriteria) @@ -82,9 +76,7 @@ namespace NzbDrone.Core.Indexers return new List(); } - var generator = GetRequestGenerator(); - - return FetchReleases(generator.GetSearchRequests(searchCriteria)); + return FetchReleases(g => g.GetSearchRequests(searchCriteria)); } public override IList Fetch(AnimeEpisodeSearchCriteria searchCriteria) @@ -94,9 +86,7 @@ namespace NzbDrone.Core.Indexers return new List(); } - var generator = GetRequestGenerator(); - - return FetchReleases(generator.GetSearchRequests(searchCriteria)); + return FetchReleases(g => g.GetSearchRequests(searchCriteria)); } public override IList Fetch(SpecialEpisodeSearchCriteria searchCriteria) @@ -106,20 +96,21 @@ namespace NzbDrone.Core.Indexers return new List(); } - var generator = GetRequestGenerator(); - - return FetchReleases(generator.GetSearchRequests(searchCriteria)); + return FetchReleases(g => g.GetSearchRequests(searchCriteria)); } - protected virtual IList FetchReleases(IndexerPageableRequestChain pageableRequestChain, bool isRecent = false) + protected virtual IList FetchReleases(Func pageableRequestChainSelector, bool isRecent = false) { var releases = new List(); var url = string.Empty; - var parser = GetParser(); - try { + var generator = GetRequestGenerator(); + var parser = GetParser(); + + var pageableRequestChain = pageableRequestChainSelector(generator); + var fullyUpdated = false; ReleaseInfo lastReleaseInfo = null; if (isRecent) @@ -175,7 +166,7 @@ namespace NzbDrone.Core.Indexers } } - releases.AddRange(pagedReleases); + releases.AddRange(pagedReleases.Where(IsValidRelease)); } if (releases.Any()) @@ -222,18 +213,22 @@ namespace NzbDrone.Core.Indexers _logger.Warn("{0} {1} {2}", this, url, webException.Message); } } - catch (HttpException httpException) + catch (TooManyRequestsException ex) { - if ((int)httpException.Response.StatusCode == 429) + if (ex.RetryAfter != TimeSpan.Zero) { - _indexerStatusService.RecordFailure(Definition.Id, TimeSpan.FromHours(1)); - _logger.Warn("API Request Limit reached for {0}", this); + _indexerStatusService.RecordFailure(Definition.Id, ex.RetryAfter); } else { - _indexerStatusService.RecordFailure(Definition.Id); - _logger.Warn("{0} {1}", this, httpException.Message); + _indexerStatusService.RecordFailure(Definition.Id, TimeSpan.FromHours(1)); } + _logger.Warn("API Request Limit reached for {0}", this); + } + catch (HttpException ex) + { + _indexerStatusService.RecordFailure(Definition.Id); + _logger.Warn("{0} {1}", this, ex.Message); } catch (RequestLimitReachedException) { @@ -248,6 +243,7 @@ namespace NzbDrone.Core.Indexers catch (CloudFlareCaptchaException ex) { _indexerStatusService.RecordFailure(Definition.Id); + ex.WithData("FeedUrl", url); if (ex.IsExpired) { _logger.Error(ex, "Expired CAPTCHA token for {0}, please refresh in indexer settings.", this); @@ -262,16 +258,26 @@ namespace NzbDrone.Core.Indexers _indexerStatusService.RecordFailure(Definition.Id); _logger.Warn(ex, "{0}", url); } - catch (Exception feedEx) + catch (Exception ex) { _indexerStatusService.RecordFailure(Definition.Id); - feedEx.Data.Add("FeedUrl", url); - _logger.Error(feedEx, "An error occurred while processing feed. {0}", url); + ex.WithData("FeedUrl", url); + _logger.Error(ex, "An error occurred while processing feed. {0}", url); } return CleanupReleases(releases); } + protected virtual bool IsValidRelease(ReleaseInfo release) + { + if (release.DownloadUrl.IsNullOrWhiteSpace()) + { + return false; + } + + return true; + } + protected virtual bool IsFullPage(IList page) { return PageSize != 0 && page.Count >= PageSize; diff --git a/src/NzbDrone.Core/Indexers/IIndexerSettings.cs b/src/NzbDrone.Core/Indexers/IIndexerSettings.cs new file mode 100644 index 000000000..87e7f03d2 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/IIndexerSettings.cs @@ -0,0 +1,9 @@ +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Indexers +{ + public interface IIndexerSettings : IProviderConfig + { + string BaseUrl { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsRequestGenerator.cs b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsRequestGenerator.cs index bf4d9e7b8..045291858 100644 --- a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsRequestGenerator.cs @@ -7,7 +7,7 @@ namespace NzbDrone.Core.Indexers.IPTorrents public class IPTorrentsRequestGenerator : IIndexerRequestGenerator { public IPTorrentsSettings Settings { get; set; } - + public virtual IndexerPageableRequestChain GetRecentRequests() { var pageableRequests = new IndexerPageableRequestChain(); @@ -44,7 +44,7 @@ namespace NzbDrone.Core.Indexers.IPTorrents private IEnumerable GetRssRequests() { - yield return new IndexerRequest(Settings.Url, HttpAccept.Rss); + yield return new IndexerRequest(Settings.BaseUrl, HttpAccept.Rss); } } } diff --git a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsSettings.cs b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsSettings.cs index 4b82353a2..16d78430b 100644 --- a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsSettings.cs +++ b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsSettings.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using FluentValidation; using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; @@ -11,26 +11,30 @@ namespace NzbDrone.Core.Indexers.IPTorrents { public IPTorrentsSettingsValidator() { - RuleFor(c => c.Url).ValidRootUrl(); + RuleFor(c => c.BaseUrl).ValidRootUrl(); - RuleFor(c => c.Url).Matches(@"/rss\?.+$"); + RuleFor(c => c.BaseUrl).Matches(@"/rss\?.+$"); - RuleFor(c => c.Url).Matches(@"/rss\?.+;download(?:;|$)") + RuleFor(c => c.BaseUrl).Matches(@"/rss\?.+;download(?:;|$)") .WithMessage("Use Direct Download Url (;download)") - .When(v => v.Url.IsNotNullOrWhiteSpace() && Regex.IsMatch(v.Url, @"/rss\?.+$")); + .When(v => v.BaseUrl.IsNotNullOrWhiteSpace() && Regex.IsMatch(v.BaseUrl, @"/rss\?.+$")); } } - public class IPTorrentsSettings : IProviderConfig + public class IPTorrentsSettings : ITorrentIndexerSettings { private static readonly IPTorrentsSettingsValidator Validator = new IPTorrentsSettingsValidator(); public IPTorrentsSettings() { + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; } [FieldDefinition(0, Label = "Feed URL", HelpText = "The full RSS feed url generated by IPTorrents, using only the categories you selected (HD, SD, x264, etc ...)")] - public string Url { get; set; } + public string BaseUrl { get; set; } + + [FieldDefinition(1, Type = FieldType.Textbox, Label = "Minimum Seeders", HelpText = "Minimum number of seeders required.", Advanced = true)] + public int MinimumSeeders { get; set; } public NzbDroneValidationResult Validate() { diff --git a/src/NzbDrone.Core/Indexers/ITorrentIndexerSettings.cs b/src/NzbDrone.Core/Indexers/ITorrentIndexerSettings.cs new file mode 100644 index 000000000..9ac4fafcb --- /dev/null +++ b/src/NzbDrone.Core/Indexers/ITorrentIndexerSettings.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Indexers +{ + public interface ITorrentIndexerSettings : IIndexerSettings + { + int MinimumSeeders { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/IndexerBase.cs b/src/NzbDrone.Core/Indexers/IndexerBase.cs index 4e08e5aad..9af0d2204 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -13,7 +13,7 @@ using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Indexers { public abstract class IndexerBase : IIndexer - where TSettings : IProviderConfig, new() + where TSettings : IIndexerSettings, new() { protected readonly IIndexerStatusService _indexerStatusService; protected readonly IConfigService _configService; @@ -96,11 +96,6 @@ namespace NzbDrone.Core.Indexers failures.Add(new ValidationFailure(string.Empty, "Test was aborted due to an error: " + ex.Message)); } - if (Definition.Id != 0) - { - _indexerStatusService.RecordSuccess(Definition.Id); - } - return new ValidationResult(failures); } diff --git a/src/NzbDrone.Core/Indexers/IndexerDefaults.cs b/src/NzbDrone.Core/Indexers/IndexerDefaults.cs new file mode 100644 index 000000000..134126fab --- /dev/null +++ b/src/NzbDrone.Core/Indexers/IndexerDefaults.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Indexers +{ + public static class IndexerDefaults + { + public const int MINIMUM_SEEDERS = 1; + } +} diff --git a/src/NzbDrone.Core/Indexers/IndexerFactory.cs b/src/NzbDrone.Core/Indexers/IndexerFactory.cs index c4903c9c7..0d918b980 100644 --- a/src/NzbDrone.Core/Indexers/IndexerFactory.cs +++ b/src/NzbDrone.Core/Indexers/IndexerFactory.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using FluentValidation.Results; using NLog; using NzbDrone.Common.Composition; using NzbDrone.Core.Messaging.Events; @@ -21,7 +22,7 @@ namespace NzbDrone.Core.Indexers public IndexerFactory(IIndexerStatusService indexerStatusService, IIndexerRepository providerRepository, IEnumerable providers, - IContainer container, + IContainer container, IEventAggregator eventAggregator, Logger logger) : base(providerRepository, providers, container, eventAggregator, logger) @@ -70,7 +71,7 @@ namespace NzbDrone.Core.Indexers private IEnumerable FilterBlockedIndexers(IEnumerable indexers) { - var blockedIndexers = _indexerStatusService.GetBlockedIndexers().ToDictionary(v => v.IndexerId, v => v); + var blockedIndexers = _indexerStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId, v => v); foreach (var indexer in indexers) { @@ -84,5 +85,17 @@ namespace NzbDrone.Core.Indexers yield return indexer; } } + + public override ValidationResult Test(IndexerDefinition definition) + { + var result = base.Test(definition); + + if ((result == null || result.IsValid) && definition.Id != 0) + { + _indexerStatusService.RecordSuccess(definition.Id); + } + + return result; + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/IndexerStatus.cs b/src/NzbDrone.Core/Indexers/IndexerStatus.cs index 662c9de64..12901563f 100644 --- a/src/NzbDrone.Core/Indexers/IndexerStatus.cs +++ b/src/NzbDrone.Core/Indexers/IndexerStatus.cs @@ -1,23 +1,10 @@ -using System; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider.Status; namespace NzbDrone.Core.Indexers { - public class IndexerStatus : ModelBase + public class IndexerStatus : ProviderStatusBase { - public int IndexerId { get; set; } - - public DateTime? InitialFailure { get; set; } - public DateTime? MostRecentFailure { get; set; } - public int EscalationLevel { get; set; } - public DateTime? DisabledTill { get; set; } - public ReleaseInfo LastRssSyncReleaseInfo { get; set; } - - public bool IsDisabled() - { - return DisabledTill.HasValue && DisabledTill.Value > DateTime.UtcNow; - } } } diff --git a/src/NzbDrone.Core/Indexers/IndexerStatusRepository.cs b/src/NzbDrone.Core/Indexers/IndexerStatusRepository.cs index 8a70b790a..616c1cdf7 100644 --- a/src/NzbDrone.Core/Indexers/IndexerStatusRepository.cs +++ b/src/NzbDrone.Core/Indexers/IndexerStatusRepository.cs @@ -1,26 +1,19 @@ -using System.Linq; -using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.ThingiProvider; - +using NzbDrone.Core.ThingiProvider.Status; namespace NzbDrone.Core.Indexers { - public interface IIndexerStatusRepository : IProviderRepository + public interface IIndexerStatusRepository : IProviderStatusRepository { - IndexerStatus FindByIndexerId(int indexerId); - } - public class IndexerStatusRepository : ProviderRepository, IIndexerStatusRepository + } + + public class IndexerStatusRepository : ProviderStatusRepository, IIndexerStatusRepository { public IndexerStatusRepository(IMainDatabase database, IEventAggregator eventAggregator) : base(database, eventAggregator) { } - - public IndexerStatus FindByIndexerId(int indexerId) - { - return Query.Where(c => c.IndexerId == indexerId).SingleOrDefault(); - } } } diff --git a/src/NzbDrone.Core/Indexers/IndexerStatusService.cs b/src/NzbDrone.Core/Indexers/IndexerStatusService.cs index 8e1bd1fe5..6d3b89545 100644 --- a/src/NzbDrone.Core/Indexers/IndexerStatusService.cs +++ b/src/NzbDrone.Core/Indexers/IndexerStatusService.cs @@ -5,149 +5,39 @@ using NLog; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider.Events; +using NzbDrone.Core.ThingiProvider.Status; namespace NzbDrone.Core.Indexers { - public interface IIndexerStatusService + public interface IIndexerStatusService : IProviderStatusServiceBase { - List GetBlockedIndexers(); ReleaseInfo GetLastRssSyncReleaseInfo(int indexerId); - void RecordSuccess(int indexerId); - void RecordFailure(int indexerId, TimeSpan minimumBackOff = default(TimeSpan)); - void RecordConnectionFailure(int indexerId); void UpdateRssSyncStatus(int indexerId, ReleaseInfo releaseInfo); } - public class IndexerStatusService : IIndexerStatusService, IHandleAsync> + public class IndexerStatusService : ProviderStatusServiceBase, IIndexerStatusService { - private static readonly int[] EscalationBackOffPeriods = { - 0, - 5 * 60, - 15 * 60, - 30 * 60, - 60 * 60, - 3 * 60 * 60, - 6 * 60 * 60, - 12 * 60 * 60, - 24 * 60 * 60 - }; - private static readonly int MaximumEscalationLevel = EscalationBackOffPeriods.Length - 1; - - private static readonly object _syncRoot = new object(); - - private readonly IIndexerStatusRepository _indexerStatusRepository; - private readonly Logger _logger; - - public IndexerStatusService(IIndexerStatusRepository indexerStatusRepository, Logger logger) + public IndexerStatusService(IIndexerStatusRepository providerStatusRepository, IEventAggregator eventAggregator, Logger logger) + : base(providerStatusRepository, eventAggregator, logger) { - _indexerStatusRepository = indexerStatusRepository; - _logger = logger; - } - public List GetBlockedIndexers() - { - return _indexerStatusRepository.All().Where(v => v.IsDisabled()).ToList(); } public ReleaseInfo GetLastRssSyncReleaseInfo(int indexerId) { - return GetIndexerStatus(indexerId).LastRssSyncReleaseInfo; + return GetProviderStatus(indexerId).LastRssSyncReleaseInfo; } - private IndexerStatus GetIndexerStatus(int indexerId) - { - return _indexerStatusRepository.FindByIndexerId(indexerId) ?? new IndexerStatus { IndexerId = indexerId }; - } - - private TimeSpan CalculateBackOffPeriod(IndexerStatus status) - { - var level = Math.Min(MaximumEscalationLevel, status.EscalationLevel); - - return TimeSpan.FromSeconds(EscalationBackOffPeriods[level]); - } - - public void RecordSuccess(int indexerId) - { - lock (_syncRoot) - { - var status = GetIndexerStatus(indexerId); - - if (status.EscalationLevel == 0) - { - return; - } - - status.EscalationLevel--; - status.DisabledTill = null; - - _indexerStatusRepository.Upsert(status); - } - } - - protected void RecordFailure(int indexerId, TimeSpan minimumBackOff, bool escalate) - { - lock (_syncRoot) - { - var status = GetIndexerStatus(indexerId); - - var now = DateTime.UtcNow; - - if (status.EscalationLevel == 0) - { - status.InitialFailure = now; - } - - status.MostRecentFailure = now; - if (escalate) - { - status.EscalationLevel = Math.Min(MaximumEscalationLevel, status.EscalationLevel + 1); - } - - if (minimumBackOff != TimeSpan.Zero) - { - while (status.EscalationLevel < MaximumEscalationLevel && CalculateBackOffPeriod(status) < minimumBackOff) - { - status.EscalationLevel++; - } - } - - status.DisabledTill = now + CalculateBackOffPeriod(status); - - _indexerStatusRepository.Upsert(status); - } - } - - public void RecordFailure(int indexerId, TimeSpan minimumBackOff = default(TimeSpan)) - { - RecordFailure(indexerId, minimumBackOff, true); - } - - public void RecordConnectionFailure(int indexerId) - { - RecordFailure(indexerId, default(TimeSpan), false); - } - - public void UpdateRssSyncStatus(int indexerId, ReleaseInfo releaseInfo) { lock (_syncRoot) { - var status = GetIndexerStatus(indexerId); + var status = GetProviderStatus(indexerId); status.LastRssSyncReleaseInfo = releaseInfo; - _indexerStatusRepository.Upsert(status); - } - } - - public void HandleAsync(ProviderDeletedEvent message) - { - var indexerStatus = _indexerStatusRepository.FindByIndexerId(message.ProviderId); - - if (indexerStatus != null) - { - _indexerStatusRepository.Delete(indexerStatus); + _providerStatusRepository.Upsert(status); } } } diff --git a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs index 4258670dd..7b87992b8 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs @@ -43,15 +43,16 @@ namespace NzbDrone.Core.Indexers.Newznab yield return GetDefinition("DrunkenSlug", GetSettings("https://api.drunkenslug.com")); yield return GetDefinition("Nzb.su", GetSettings("https://api.nzb.su")); yield return GetDefinition("NZBCat", GetSettings("https://nzb.cat")); - yield return GetDefinition("NZBFinder.ws", GetSettings("https://nzbfinder.ws", 5010, 5030, 5040, 5045)); + yield return GetDefinition("NZBFinder.ws", GetSettings("https://nzbfinder.ws", categories: new[] { 5010, 5030, 5040, 5045 })); yield return GetDefinition("NZBgeek", GetSettings("https://api.nzbgeek.info")); yield return GetDefinition("nzbplanet.net", GetSettings("https://api.nzbplanet.net")); - yield return GetDefinition("Nzbs.org", GetSettings("http://nzbs.org", 5000)); + yield return GetDefinition("Nzbs.org", GetSettings("http://nzbs.org", categories: new[] { 5000 })); yield return GetDefinition("omgwtfnzbs", GetSettings("https://api.omgwtfnzbs.me")); yield return GetDefinition("OZnzb.com", GetSettings("https://api.oznzb.com")); yield return GetDefinition("PFmonkey", GetSettings("https://www.pfmonkey.com")); yield return GetDefinition("SimplyNZBs", GetSettings("https://simplynzbs.com")); yield return GetDefinition("Usenet Crawler", GetSettings("https://www.usenet-crawler.com")); + yield return GetDefinition("AnimeTosho Usenet", GetSettings("https://animetosho.org", apiPath: @"/feed/nabapi", categories: new int[0], animeCategories: new[] { 5070 })); } } @@ -76,22 +77,32 @@ namespace NzbDrone.Core.Indexers.Newznab }; } - private NewznabSettings GetSettings(string url, params int[] categories) + private NewznabSettings GetSettings(string url, string apiPath = null, int[] categories = null, int[] animeCategories = null) { - var settings = new NewznabSettings { Url = url }; + var settings = new NewznabSettings { BaseUrl = url }; - if (categories.Any()) + if (categories != null) { settings.Categories = categories; } + if (animeCategories != null) + { + settings.AnimeCategories = animeCategories; + } + + if (apiPath.IsNotNullOrWhiteSpace()) + { + settings.ApiPath = apiPath; + } + return settings; } protected override void Test(List failures) { base.Test(failures); - + if (failures.Any()) return; failures.AddIfNotNull(TestCapabilities()); } diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs index a3eac6d23..1c955c75d 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs @@ -41,7 +41,7 @@ namespace NzbDrone.Core.Indexers.Newznab { var capabilities = new NewznabCapabilities(); - var url = string.Format("{0}/api?t=caps", indexerSettings.Url.TrimEnd('/')); + var url = string.Format("{0}{1}?t=caps", indexerSettings.BaseUrl.TrimEnd('/'), indexerSettings.ApiPath.TrimEnd('/')); if (indexerSettings.ApiKey.IsNotNullOrWhiteSpace()) { @@ -58,7 +58,7 @@ namespace NzbDrone.Core.Indexers.Newznab } catch (Exception ex) { - _logger.Debug(ex, "Failed to get newznab api capabilities from {0}", indexerSettings.Url); + _logger.Debug(ex, "Failed to get newznab api capabilities from {0}", indexerSettings.BaseUrl); throw; } @@ -68,12 +68,14 @@ namespace NzbDrone.Core.Indexers.Newznab } catch (XmlException ex) { - _logger.Debug(ex, "Failed to parse newznab api capabilities for {0}.", indexerSettings.Url); + _logger.Debug(ex, "Failed to parse newznab api capabilities for {0}", indexerSettings.BaseUrl); + + ex.WithData(response); throw; } catch (Exception ex) { - _logger.Error(ex, "Failed to determine newznab api capabilities for {0}, using the defaults instead till Sonarr restarts.", indexerSettings.Url); + _logger.Error(ex, "Failed to determine newznab api capabilities for {0}, using the defaults instead till Sonarr restarts", indexerSettings.BaseUrl); } return capabilities; @@ -83,7 +85,19 @@ namespace NzbDrone.Core.Indexers.Newznab { var capabilities = new NewznabCapabilities(); - var xmlRoot = XDocument.Parse(response.Content).Element("caps"); + var xDoc = XDocument.Parse(response.Content); + + if (xDoc == null) + { + throw new XmlException("Invalid XML"); + } + + var xmlRoot = xDoc.Element("caps"); + + if (xmlRoot == null) + { + throw new XmlException("Unexpected XML"); + } var xmlLimits = xmlRoot.Element("limits"); if (xmlLimits != null) diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs index 915603c15..10d1fb9c2 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs @@ -104,6 +104,10 @@ namespace NzbDrone.Core.Indexers.Newznab { pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories.Concat(Settings.AnimeCategories), "tvsearch", "")); } + else if (capabilities.SupportedSearchParameters != null) + { + pageableRequests.Add(GetPagedRequests(MaxPages, Settings.AnimeCategories, "search", "")); + } return pageableRequests; } @@ -249,7 +253,7 @@ namespace NzbDrone.Core.Indexers.Newznab var categoriesQuery = string.Join(",", categories.Distinct()); - var baseUrl = string.Format("{0}/api?t={1}&cat={2}&extended=1{3}", Settings.Url.TrimEnd('/'), searchType, categoriesQuery, Settings.AdditionalParameters); + var baseUrl = string.Format("{0}{1}?t={2}&cat={3}&extended=1{4}", Settings.BaseUrl.TrimEnd('/'), Settings.ApiPath.TrimEnd('/'), searchType, categoriesQuery, Settings.AdditionalParameters); if (Settings.ApiKey.IsNotNullOrWhiteSpace()) { diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabRssParser.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabRssParser.cs index 16c4dea9b..75b244fda 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabRssParser.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabRssParser.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Xml.Linq; using NzbDrone.Common.Extensions; @@ -13,7 +14,8 @@ namespace NzbDrone.Core.Indexers.Newznab public NewznabRssParser() { - PreferredEnclosureMimeType = "application/x-nzb"; + PreferredEnclosureMimeTypes = UsenetEnclosureMimeTypes; + UseEnclosureUrl = true; } protected override bool PreProcess(IndexerResponse indexerResponse) @@ -45,6 +47,24 @@ namespace NzbDrone.Core.Indexers.Newznab throw new NewznabException(indexerResponse, errorMessage); } + protected override bool PostProcess(IndexerResponse indexerResponse, List items, List releases) + { + var enclosureTypes = items.SelectMany(GetEnclosures).Select(v => v.Type).Distinct().ToArray(); + if (enclosureTypes.Any() && enclosureTypes.Intersect(PreferredEnclosureMimeTypes).Empty()) + { + if (enclosureTypes.Intersect(TorrentEnclosureMimeTypes).Any()) + { + _logger.Warn("Feed does not contain {0}, found {1}, did you intend to add a Torznab indexer?", NzbEnclosureMimeType, enclosureTypes[0]); + } + else + { + _logger.Warn("Feed does not contain {0}, found {1}.", NzbEnclosureMimeType, enclosureTypes[0]); + } + } + + return true; + } + protected override ReleaseInfo ProcessItem(XElement item, ReleaseInfo releaseInfo) { releaseInfo = base.ProcessItem(item, releaseInfo); @@ -55,17 +75,6 @@ namespace NzbDrone.Core.Indexers.Newznab return releaseInfo; } - protected override ReleaseInfo PostProcess(XElement item, ReleaseInfo releaseInfo) - { - var enclosureType = GetEnclosure(item).Attribute("type").Value; - if (enclosureType.Contains("application/x-bittorrent")) - { - throw new UnsupportedFeedException("Feed contains {0}, did you intend to add a Torznab indexer?", enclosureType); - } - - return base.PostProcess(item, releaseInfo); - } - protected override string GetInfoUrl(XElement item) { return ParseUrl(item.TryGetValue("comments").TrimEnd("#comments")); @@ -102,18 +111,6 @@ namespace NzbDrone.Core.Indexers.Newznab return base.GetPublishDate(item); } - protected override string GetDownloadUrl(XElement item) - { - var url = base.GetDownloadUrl(item); - - if (!Uri.IsWellFormedUriString(url, UriKind.Absolute)) - { - url = ParseUrl((string)item.Element("enclosure").Attribute("url")); - } - - return url; - } - protected virtual int GetTvdbId(XElement item) { var tvdbIdString = TryGetNewznabAttribute(item, "tvdbid"); @@ -142,11 +139,14 @@ namespace NzbDrone.Core.Indexers.Newznab protected string TryGetNewznabAttribute(XElement item, string key, string defaultValue = "") { - var attr = item.Elements(ns + "attr").FirstOrDefault(e => e.Attribute("name").Value.Equals(key, StringComparison.CurrentCultureIgnoreCase)); - - if (attr != null) + var attrElement = item.Elements(ns + "attr").FirstOrDefault(e => e.Attribute("name").Value.Equals(key, StringComparison.OrdinalIgnoreCase)); + if (attrElement != null) { - return attr.Attribute("value").Value; + var attrValue = attrElement.Attribute("value"); + if (attrValue != null) + { + return attrValue.Value; + } } return defaultValue; diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs index b33ef566d..ee7bd3d9d 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using FluentValidation; @@ -25,12 +25,12 @@ namespace NzbDrone.Core.Indexers.Newznab private static bool ShouldHaveApiKey(NewznabSettings settings) { - if (settings.Url == null) + if (settings.BaseUrl == null) { return false; } - return ApiKeyWhiteList.Any(c => settings.Url.ToLowerInvariant().Contains(c)); + return ApiKeyWhiteList.Any(c => settings.BaseUrl.ToLowerInvariant().Contains(c)); } private static readonly Regex AdditionalParametersRegex = new Regex(@"(&.+?\=.+?)+", RegexOptions.Compiled); @@ -47,41 +47,49 @@ namespace NzbDrone.Core.Indexers.Newznab return null; }); - RuleFor(c => c.Url).ValidRootUrl(); + RuleFor(c => c.BaseUrl).ValidRootUrl(); + RuleFor(c => c.ApiPath).ValidUrlBase("/api"); RuleFor(c => c.ApiKey).NotEmpty().When(ShouldHaveApiKey); RuleFor(c => c.AdditionalParameters).Matches(AdditionalParametersRegex) .When(c => !c.AdditionalParameters.IsNullOrWhiteSpace()); } } - public class NewznabSettings : IProviderConfig + public class NewznabSettings : IIndexerSettings { private static readonly NewznabSettingsValidator Validator = new NewznabSettingsValidator(); public NewznabSettings() { + ApiPath = "/api"; Categories = new[] { 5030, 5040 }; AnimeCategories = Enumerable.Empty(); } [FieldDefinition(0, Label = "URL")] - public string Url { get; set; } + public string BaseUrl { get; set; } - [FieldDefinition(1, Label = "API Key")] + [FieldDefinition(1, Label = "API Path", HelpText = "Path to the api, usually /api", Advanced = true)] + public string ApiPath { get; set; } + + [FieldDefinition(2, Label = "API Key")] public string ApiKey { get; set; } - [FieldDefinition(2, Label = "Categories", HelpText = "Comma Separated list, leave blank to disable standard/daily shows", Advanced = true)] + [FieldDefinition(3, Label = "Categories", HelpText = "Comma Separated list, leave blank to disable standard/daily shows")] public IEnumerable Categories { get; set; } - [FieldDefinition(3, Label = "Anime Categories", HelpText = "Comma Separated list, leave blank to disable anime", Advanced = true)] + [FieldDefinition(4, Label = "Anime Categories", HelpText = "Comma Separated list, leave blank to disable anime")] public IEnumerable AnimeCategories { get; set; } - [FieldDefinition(4, Label = "Additional Parameters", HelpText = "Additional Newznab parameters", Advanced = true)] + [FieldDefinition(5, Label = "Additional Parameters", HelpText = "Additional Newznab parameters", Advanced = true)] public string AdditionalParameters { get; set; } + // Field 6 is used by TorznabSettings MinimumSeeders + // If you need to add another field here, update TorznabSettings as well and this comment + public virtual NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs b/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs index 5977c2782..33661c6d4 100644 --- a/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs +++ b/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs @@ -1,6 +1,5 @@ using FluentValidation; using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; using System.Text.RegularExpressions; namespace NzbDrone.Core.Indexers.Nyaa @@ -14,14 +13,15 @@ namespace NzbDrone.Core.Indexers.Nyaa } } - public class NyaaSettings : IProviderConfig + public class NyaaSettings : ITorrentIndexerSettings { private static readonly NyaaSettingsValidator Validator = new NyaaSettingsValidator(); public NyaaSettings() { - BaseUrl = "https://www.nyaa.se"; + BaseUrl = ""; AdditionalParameters = "&cats=1_37&filter=1"; + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; } [FieldDefinition(0, Label = "Website URL")] @@ -30,9 +30,12 @@ namespace NzbDrone.Core.Indexers.Nyaa [FieldDefinition(1, Label = "Additional Parameters", Advanced = true, HelpText = "Please note if you change the category you will have to add required/restricted rules about the subgroups to avoid foreign language releases.")] public string AdditionalParameters { get; set; } + [FieldDefinition(2, Type = FieldType.Textbox, Label = "Minimum Seeders", HelpText = "Minimum number of seeders required.", Advanced = true)] + public int MinimumSeeders { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs index 17663e8bf..5fc677c46 100644 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs @@ -91,7 +91,7 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs private IEnumerable GetPagedRequests(string query) { var url = new StringBuilder(); - url.AppendFormat("{0}?catid=19,20&user={1}&api={2}&eng=1&delay={3}", BaseUrl, Settings.Username, Settings.ApiKey, Settings.Delay); + url.AppendFormat("{0}?catid=19,20,30&user={1}&api={2}&eng=1&delay={3}", BaseUrl, Settings.Username, Settings.ApiKey, Settings.Delay); if (query.IsNotNullOrWhiteSpace()) { diff --git a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsSettings.cs b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsSettings.cs index fe6217361..5f5fed9b1 100644 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsSettings.cs +++ b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsSettings.cs @@ -15,7 +15,7 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs } } - public class OmgwtfnzbsSettings : IProviderConfig + public class OmgwtfnzbsSettings : IIndexerSettings { private static readonly OmgwtfnzbsSettingsValidator Validator = new OmgwtfnzbsSettingsValidator(); @@ -24,6 +24,9 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs Delay = 30; } + // Unused since Omg has a hardcoded url. + public string BaseUrl { get; set; } + [FieldDefinition(0, Label = "Username")] public string Username { get; set; } diff --git a/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs b/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs index c60616b27..2b4d76020 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs @@ -1,6 +1,5 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.Rarbg @@ -13,7 +12,7 @@ namespace NzbDrone.Core.Indexers.Rarbg } } - public class RarbgSettings : IProviderConfig + public class RarbgSettings : ITorrentIndexerSettings { private static readonly RarbgSettingsValidator Validator = new RarbgSettingsValidator(); @@ -21,6 +20,7 @@ namespace NzbDrone.Core.Indexers.Rarbg { BaseUrl = "https://torrentapi.org"; RankedOnly = false; + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; } [FieldDefinition(0, Label = "API URL", HelpText = "URL to Rarbg api, not the website.")] @@ -28,13 +28,16 @@ namespace NzbDrone.Core.Indexers.Rarbg [FieldDefinition(1, Type = FieldType.Checkbox, Label = "Ranked Only", HelpText = "Only include ranked results.")] public bool RankedOnly { get; set; } - + [FieldDefinition(2, Type = FieldType.Captcha, Label = "CAPTCHA Token", HelpText = "CAPTCHA Clearance token used to handle CloudFlare Anti-DDOS measures on shared-ip VPNs.")] public string CaptchaToken { get; set; } + [FieldDefinition(3, Type = FieldType.Textbox, Label = "Minimum Seeders", HelpText = "Minimum number of seeders required.", Advanced = true)] + public int MinimumSeeders { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/RssEnclosure.cs b/src/NzbDrone.Core/Indexers/RssEnclosure.cs new file mode 100644 index 000000000..de46e8d14 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/RssEnclosure.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Indexers +{ + public class RssEnclosure + { + public string Url { get; set; } + public string Type { get; set; } + public long Length { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/RssParser.cs b/src/NzbDrone.Core/Indexers/RssParser.cs index 4a4919288..5687adb51 100644 --- a/src/NzbDrone.Core/Indexers/RssParser.cs +++ b/src/NzbDrone.Core/Indexers/RssParser.cs @@ -19,6 +19,11 @@ namespace NzbDrone.Core.Indexers public class RssParser : IParseIndexerResponse { private static readonly Regex ReplaceEntities = new Regex("&[a-z]+;", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public const string NzbEnclosureMimeType = "application/x-nzb"; + public const string TorrentEnclosureMimeType = "application/x-bittorrent"; + public const string MagnetEnclosureMimeType = "application/x-bittorrent;x-scheme-handler/magnet"; + public static readonly string[] UsenetEnclosureMimeTypes = new[] { NzbEnclosureMimeType }; + public static readonly string[] TorrentEnclosureMimeTypes = new[] { TorrentEnclosureMimeType, MagnetEnclosureMimeType }; protected readonly Logger _logger; @@ -32,7 +37,7 @@ namespace NzbDrone.Core.Indexers // Parse "Size: 1.3 GB" or "1.3 GB" parts in the description element and use that as Size. public bool ParseSizeInDescription { get; set; } - public string PreferredEnclosureMimeType { get; set; } + public string[] PreferredEnclosureMimeTypes { get; set; } private IndexerResponse _indexerResponse; @@ -53,7 +58,7 @@ namespace NzbDrone.Core.Indexers } var document = LoadXmlDocument(indexerResponse); - var items = GetItems(document); + var items = GetItems(document).ToList(); foreach (var item in items) { @@ -63,13 +68,25 @@ namespace NzbDrone.Core.Indexers releases.AddIfNotNull(reportInfo); } + catch (UnsupportedFeedException itemEx) + { + itemEx.WithData("FeedUrl", indexerResponse.Request.Url); + itemEx.WithData("ItemTitle", item.Title()); + throw; + } catch (Exception itemEx) { - itemEx.Data.Add("Item", item.Title()); + itemEx.WithData("FeedUrl", indexerResponse.Request.Url); + itemEx.WithData("ItemTitle", item.Title()); _logger.Error(itemEx, "An error occurred while processing feed item from {0}", indexerResponse.Request.Url); } } + if (!PostProcess(indexerResponse, items, releases)) + { + return new List(); + } + return releases; } @@ -90,8 +107,7 @@ namespace NzbDrone.Core.Indexers var contentSample = indexerResponse.Content.Substring(0, Math.Min(indexerResponse.Content.Length, 512)); _logger.Debug("Truncated response content (originally {0} characters): {1}", indexerResponse.Content.Length, contentSample); - ex.Data.Add("ContentLength", indexerResponse.Content.Length); - ex.Data.Add("ContentSample", contentSample); + ex.WithData(indexerResponse.HttpResponse); throw; } @@ -118,6 +134,11 @@ namespace NzbDrone.Core.Indexers return true; } + protected virtual bool PostProcess(IndexerResponse indexerResponse, List elements, List releases) + { + return true; + } + protected ReleaseInfo ProcessItem(XElement item) { var releaseInfo = CreateNewReleaseInfo(); @@ -126,7 +147,7 @@ namespace NzbDrone.Core.Indexers _logger.Trace("Parsed: {0}", releaseInfo.Title); - return PostProcess(item, releaseInfo); + return PostProcessItem(item, releaseInfo); } protected virtual ReleaseInfo ProcessItem(XElement item, ReleaseInfo releaseInfo) @@ -150,7 +171,7 @@ namespace NzbDrone.Core.Indexers return releaseInfo; } - protected virtual ReleaseInfo PostProcess(XElement item, ReleaseInfo releaseInfo) + protected virtual ReleaseInfo PostProcessItem(XElement item, ReleaseInfo releaseInfo) { return releaseInfo; } @@ -181,7 +202,8 @@ namespace NzbDrone.Core.Indexers { if (UseEnclosureUrl) { - return ParseUrl((string)GetEnclosure(item).Attribute("url")); + var enclosure = GetEnclosure(item); + return enclosure != null ? ParseUrl(enclosure.Url) : null; } return ParseUrl((string)item.Element("link")); @@ -222,37 +244,59 @@ namespace NzbDrone.Core.Indexers if (enclosure != null) { - return (long)enclosure.Attribute("length"); + return enclosure.Length; } return 0; } - protected virtual XElement GetEnclosure(XElement item) + protected virtual RssEnclosure[] GetEnclosures(XElement item) { - var enclosures = item.Elements("enclosure").ToArray(); + var enclosures = item.Elements("enclosure") + .Select(v => new RssEnclosure + { + Url = v.Attribute("url").Value, + Type = v.Attribute("type").Value, + Length = (long)v.Attribute("length") + }) + .ToArray(); + return enclosures; + } + + protected RssEnclosure GetEnclosure(XElement item, bool enforceMimeType = true) + { + var enclosures = GetEnclosures(item); + + return GetEnclosure(enclosures, enforceMimeType); + } + + protected virtual RssEnclosure GetEnclosure(RssEnclosure[] enclosures, bool enforceMimeType = true) + { if (enclosures.Length == 0) { return null; } - if (enclosures.Length == 1) + if (PreferredEnclosureMimeTypes != null) { - return enclosures.First(); - } - - if (PreferredEnclosureMimeType != null) - { - var preferredEnclosure = enclosures.FirstOrDefault(v => v.Attribute("type").Value == PreferredEnclosureMimeType); - - if (preferredEnclosure != null) + foreach (var preferredEnclosureType in PreferredEnclosureMimeTypes) { - return preferredEnclosure; + var preferredEnclosure = enclosures.FirstOrDefault(v => v.Type == preferredEnclosureType); + + if (preferredEnclosure != null) + { + return preferredEnclosure; + } + } + + if (enforceMimeType) + { + return null; } } - return item.Elements("enclosure").SingleOrDefault(); + return enclosures.SingleOrDefault(); } protected IEnumerable GetItems(XDocument document) diff --git a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerSettings.cs b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerSettings.cs index ef2b74f9a..8c24f9ba4 100644 --- a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerSettings.cs +++ b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerSettings.cs @@ -1,6 +1,5 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.TorrentRss @@ -13,7 +12,7 @@ namespace NzbDrone.Core.Indexers.TorrentRss } } - public class TorrentRssIndexerSettings : IProviderConfig + public class TorrentRssIndexerSettings : ITorrentIndexerSettings { private static readonly TorrentRssIndexerSettingsValidator validator = new TorrentRssIndexerSettingsValidator(); @@ -21,6 +20,7 @@ namespace NzbDrone.Core.Indexers.TorrentRss { BaseUrl = string.Empty; AllowZeroSize = false; + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; } [FieldDefinition(0, Label = "Full RSS Feed URL")] @@ -32,9 +32,12 @@ namespace NzbDrone.Core.Indexers.TorrentRss [FieldDefinition(2, Type = FieldType.Checkbox, Label = "Allow Zero Size", HelpText="Enabling this will allow you to use feeds that don't specify release size, but be careful, size related checks will not be performed.")] public bool AllowZeroSize { get; set; } + [FieldDefinition(3, Type = FieldType.Textbox, Label = "Minimum Seeders", HelpText = "Minimum number of seeders required.", Advanced = true)] + public int MinimumSeeders { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(validator.Validate(this)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssSettingsDetector.cs b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssSettingsDetector.cs index c20c09b2a..53ee117cc 100644 --- a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssSettingsDetector.cs +++ b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssSettingsDetector.cs @@ -40,23 +40,31 @@ namespace NzbDrone.Core.Indexers.TorrentRss { _logger.Debug("Evaluating TorrentRss feed '{0}'", indexerSettings.BaseUrl); - var requestGenerator = new TorrentRssIndexerRequestGenerator { Settings = indexerSettings }; - var request = requestGenerator.GetRecentRequests().GetAllTiers().First().First(); - - HttpResponse httpResponse = null; try { - httpResponse = _httpClient.Execute(request.HttpRequest); + var requestGenerator = new TorrentRssIndexerRequestGenerator { Settings = indexerSettings }; + var request = requestGenerator.GetRecentRequests().GetAllTiers().First().First(); + + HttpResponse httpResponse = null; + try + { + httpResponse = _httpClient.Execute(request.HttpRequest); + } + catch (Exception ex) + { + _logger.Warn(ex, string.Format("Unable to connect to indexer {0}: {1}", request.Url, ex.Message)); + return null; + } + + var indexerResponse = new IndexerResponse(request, httpResponse); + return GetParserSettings(indexerResponse, indexerSettings); } catch (Exception ex) { - _logger.Warn(ex, string.Format("Unable to connect to indexer {0}: {1}", request.Url, ex.Message)); - return null; + ex.WithData("FeedUrl", indexerSettings.BaseUrl); + throw; } - - var indexerResponse = new IndexerResponse(request, httpResponse); - return GetParserSettings(indexerResponse, indexerSettings); - } + } private TorrentRssIndexerParserSettings GetParserSettings(IndexerResponse response, TorrentRssIndexerSettings indexerSettings) { @@ -140,7 +148,7 @@ namespace NzbDrone.Core.Indexers.TorrentRss _logger.Trace("Feed doesn't have Seeders in Description, disabling option."); parser.ParseSeedersInDescription = settings.ParseSeedersInDescription = false; } - + if (!releases.Any(r => r.Size < ValidSizeThreshold)) { _logger.Trace("Feed has valid size in enclosure."); diff --git a/src/NzbDrone.Core/Indexers/TorrentRssParser.cs b/src/NzbDrone.Core/Indexers/TorrentRssParser.cs index b77022540..339618294 100644 --- a/src/NzbDrone.Core/Indexers/TorrentRssParser.cs +++ b/src/NzbDrone.Core/Indexers/TorrentRssParser.cs @@ -16,7 +16,7 @@ namespace NzbDrone.Core.Indexers public TorrentRssParser() { - PreferredEnclosureMimeType = "application/x-bittorrent"; + PreferredEnclosureMimeTypes = TorrentEnclosureMimeTypes; } public IEnumerable GetItems(IndexerResponse indexerResponse) diff --git a/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs index 957bfc3ed..f7b06ec59 100644 --- a/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs +++ b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs @@ -1,6 +1,5 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.Torrentleech @@ -14,13 +13,14 @@ namespace NzbDrone.Core.Indexers.Torrentleech } } - public class TorrentleechSettings : IProviderConfig + public class TorrentleechSettings : ITorrentIndexerSettings { private static readonly TorrentleechSettingsValidator Validator = new TorrentleechSettingsValidator(); public TorrentleechSettings() { BaseUrl = "http://rss.torrentleech.org"; + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; } [FieldDefinition(0, Label = "Website URL")] @@ -29,9 +29,12 @@ namespace NzbDrone.Core.Indexers.Torrentleech [FieldDefinition(1, Label = "API Key")] public string ApiKey { get; set; } + [FieldDefinition(2, Type = FieldType.Textbox, Label = "Minimum Seeders", HelpText = "Minimum number of seeders required.", Advanced = true)] + public int MinimumSeeders { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs index 8d2649c2d..bb9c4c3de 100644 --- a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs +++ b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs @@ -40,6 +40,8 @@ namespace NzbDrone.Core.Indexers.Torznab get { yield return GetDefinition("HD4Free.xyz", GetSettings("http://hd4free.xyz")); + yield return GetDefinition("AnimeTosho Torrents", GetSettings("https://animetosho.org", apiPath: @"/feed/nabapi", categories: new int[0], animeCategories: new[] { 5070 })); + yield return GetDefinition("Nyaa Pantsu", GetSettings("https://nyaa.pantsu.cat", apiPath: @"/feed/torznab", categories: new int[0], animeCategories: new[] { 5070 })); } } @@ -64,22 +66,32 @@ namespace NzbDrone.Core.Indexers.Torznab }; } - private TorznabSettings GetSettings(string url, params int[] categories) + private TorznabSettings GetSettings(string url, string apiPath = null, int[] categories = null, int[] animeCategories = null) { - var settings = new TorznabSettings { Url = url }; + var settings = new TorznabSettings { BaseUrl = url }; - if (categories.Any()) + if (categories != null) { settings.Categories = categories; } + if (animeCategories != null) + { + settings.AnimeCategories = animeCategories; + } + + if (apiPath.IsNotNullOrWhiteSpace()) + { + settings.ApiPath = apiPath; + } + return settings; } protected override void Test(List failures) { base.Test(failures); - + if (failures.Any()) return; failures.AddIfNotNull(TestCapabilities()); } diff --git a/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs b/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs index 253386963..9fb5614c1 100644 --- a/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs +++ b/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Xml.Linq; using NzbDrone.Common.Extensions; @@ -11,6 +12,11 @@ namespace NzbDrone.Core.Indexers.Torznab { public const string ns = "{http://torznab.com/schemas/2015/feed}"; + public TorznabRssParser() + { + UseEnclosureUrl = true; + } + protected override bool PreProcess(IndexerResponse indexerResponse) { var xdoc = LoadXmlDocument(indexerResponse); @@ -36,6 +42,24 @@ namespace NzbDrone.Core.Indexers.Torznab throw new TorznabException("Torznab error detected: {0}", errorMessage); } + protected override bool PostProcess(IndexerResponse indexerResponse, List items, List releases) + { + var enclosureTypes = items.SelectMany(GetEnclosures).Select(v => v.Type).Distinct().ToArray(); + if (enclosureTypes.Any() && enclosureTypes.Intersect(PreferredEnclosureMimeTypes).Empty()) + { + if (enclosureTypes.Intersect(UsenetEnclosureMimeTypes).Any()) + { + _logger.Warn("Feed does not contain {0}, found {1}, did you intend to add a Newznab indexer?", TorrentEnclosureMimeType, enclosureTypes[0]); + } + else + { + _logger.Warn("Feed does not contain {0}, found {1}.", TorrentEnclosureMimeType, enclosureTypes[0]); + } + } + + return true; + } + protected override ReleaseInfo ProcessItem(XElement item, ReleaseInfo releaseInfo) { var torrentInfo = base.ProcessItem(item, releaseInfo) as TorrentInfo; @@ -46,18 +70,6 @@ namespace NzbDrone.Core.Indexers.Torznab return torrentInfo; } - protected override ReleaseInfo PostProcess(XElement item, ReleaseInfo releaseInfo) - { - var enclosureType = item.Element("enclosure").Attribute("type").Value; - if (!enclosureType.Contains("application/x-bittorrent")) - { - throw new UnsupportedFeedException("Feed contains {0} instead of application/x-bittorrent", enclosureType); - } - - return base.PostProcess(item, releaseInfo); - } - - protected override string GetInfoUrl(XElement item) { return ParseUrl(item.TryGetValue("comments").TrimEnd("#comments")); @@ -169,11 +181,14 @@ namespace NzbDrone.Core.Indexers.Torznab protected string TryGetTorznabAttribute(XElement item, string key, string defaultValue = "") { - var attr = item.Elements(ns + "attr").FirstOrDefault(e => e.Attribute("name").Value.Equals(key, StringComparison.CurrentCultureIgnoreCase)); - - if (attr != null) + var attrElement = item.Elements(ns + "attr").FirstOrDefault(e => e.Attribute("name").Value.Equals(key, StringComparison.OrdinalIgnoreCase)); + if (attrElement != null) { - return attr.Attribute("value").Value; + var attrValue = attrElement.Attribute("value"); + if (attrValue != null) + { + return attrValue.Value; + } } return defaultValue; diff --git a/src/NzbDrone.Core/Indexers/Torznab/TorznabSettings.cs b/src/NzbDrone.Core/Indexers/Torznab/TorznabSettings.cs index 86d7be1a1..bbbfcfce5 100644 --- a/src/NzbDrone.Core/Indexers/Torznab/TorznabSettings.cs +++ b/src/NzbDrone.Core/Indexers/Torznab/TorznabSettings.cs @@ -1,8 +1,9 @@ -using System.Linq; +using System.Linq; using System.Text.RegularExpressions; using FluentValidation; using FluentValidation.Results; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; using NzbDrone.Core.Indexers.Newznab; using NzbDrone.Core.Validation; @@ -17,12 +18,12 @@ namespace NzbDrone.Core.Indexers.Torznab private static bool ShouldHaveApiKey(TorznabSettings settings) { - if (settings.Url == null) + if (settings.BaseUrl == null) { return false; } - return ApiKeyWhiteList.Any(c => settings.Url.ToLowerInvariant().Contains(c)); + return ApiKeyWhiteList.Any(c => settings.BaseUrl.ToLowerInvariant().Contains(c)); } private static readonly Regex AdditionalParametersRegex = new Regex(@"(&.+?\=.+?)+", RegexOptions.Compiled); @@ -39,20 +40,29 @@ namespace NzbDrone.Core.Indexers.Torznab return null; }); - RuleFor(c => c.Url).ValidRootUrl(); + RuleFor(c => c.BaseUrl).ValidRootUrl(); + RuleFor(c => c.ApiPath).ValidUrlBase("/api"); RuleFor(c => c.ApiKey).NotEmpty().When(ShouldHaveApiKey); RuleFor(c => c.AdditionalParameters).Matches(AdditionalParametersRegex) .When(c => !c.AdditionalParameters.IsNullOrWhiteSpace()); } } - public class TorznabSettings : NewznabSettings + public class TorznabSettings : NewznabSettings, ITorrentIndexerSettings { private static readonly TorznabSettingsValidator Validator = new TorznabSettingsValidator(); + public TorznabSettings() + { + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; + } + + [FieldDefinition(6, Type = FieldType.Textbox, Label = "Minimum Seeders", HelpText = "Minimum number of seeders required.", Advanced = true)] + public int MinimumSeeders { get; set; } + public override NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs index 2f6ba4b25..dd1a87856 100644 --- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -24,7 +24,7 @@ namespace NzbDrone.Core.MediaFiles void Scan(Series series); string[] GetVideoFiles(string path, bool allDirectories = true); string[] GetNonVideoFiles(string path, bool allDirectories = true); - List FilterFiles(Series series, IEnumerable files); + List FilterFiles(string basePath, IEnumerable files); } public class DiskScanService : @@ -60,8 +60,8 @@ namespace NzbDrone.Core.MediaFiles _logger = logger; } - private static readonly Regex ExcludedSubFoldersRegex = new Regex(@"(?:\\|\/|^)(extras|@eadir|extrafanart|plex\sversions|\..+)(?:\\|\/)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex ExcludedFilesRegex = new Regex(@"^\._|Thumbs\.db", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex ExcludedSubFoldersRegex = new Regex(@"(?:\\|\/|^)(?:extras|@eadir|extrafanart|plex versions|\.[^\\/]+)(?:\\|\/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex ExcludedFilesRegex = new Regex(@"^\._|^Thumbs\.db$", RegexOptions.Compiled | RegexOptions.IgnoreCase); public void Scan(Series series) { @@ -101,7 +101,7 @@ namespace NzbDrone.Core.MediaFiles } var videoFilesStopwatch = Stopwatch.StartNew(); - var mediaFileList = FilterFiles(series, GetVideoFiles(series.Path)).ToList(); + var mediaFileList = FilterFiles(series.Path, GetVideoFiles(series.Path)).ToList(); videoFilesStopwatch.Stop(); _logger.Trace("Finished getting episode files for: {0} [{1}]", series, videoFilesStopwatch.Elapsed); @@ -135,7 +135,7 @@ namespace NzbDrone.Core.MediaFiles var searchOption = allDirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; var filesOnDisk = _diskProvider.GetFiles(path, searchOption).ToList(); - var mediaFileList = filesOnDisk.Where(file => MediaFileExtensions.Extensions.Contains(Path.GetExtension(file).ToLower())) + var mediaFileList = filesOnDisk.Where(file => MediaFileExtensions.Extensions.Contains(Path.GetExtension(file))) .ToList(); _logger.Trace("{0} files were found in {1}", filesOnDisk.Count, path); @@ -150,7 +150,7 @@ namespace NzbDrone.Core.MediaFiles var searchOption = allDirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; var filesOnDisk = _diskProvider.GetFiles(path, searchOption).ToList(); - var mediaFileList = filesOnDisk.Where(file => !MediaFileExtensions.Extensions.Contains(Path.GetExtension(file).ToLower())) + var mediaFileList = filesOnDisk.Where(file => !MediaFileExtensions.Extensions.Contains(Path.GetExtension(file))) .ToList(); _logger.Trace("{0} files were found in {1}", filesOnDisk.Count, path); @@ -158,9 +158,9 @@ namespace NzbDrone.Core.MediaFiles return mediaFileList.ToArray(); } - public List FilterFiles(Series series, IEnumerable files) + public List FilterFiles(string basePath, IEnumerable files) { - return files.Where(file => !ExcludedSubFoldersRegex.IsMatch(series.Path.GetRelativePath(file))) + return files.Where(file => !ExcludedSubFoldersRegex.IsMatch(basePath.GetRelativePath(file))) .Where(file => !ExcludedFilesRegex.IsMatch(Path.GetFileName(file))) .ToList(); } @@ -184,7 +184,7 @@ namespace NzbDrone.Core.MediaFiles _logger.Warn(ex, "Unable to apply permissions to: " + path); _logger.Debug(ex, ex.Message); } - } + } public void Handle(SeriesUpdatedEvent message) { @@ -210,4 +210,4 @@ namespace NzbDrone.Core.MediaFiles } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs index 69e154a72..aa7b4a2ec 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs @@ -1,14 +1,15 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.Parser; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.MediaFiles { @@ -101,7 +102,7 @@ namespace NzbDrone.Core.MediaFiles public bool ShouldDeleteFolder(DirectoryInfo directoryInfo, Series series) { var videoFiles = _diskScanService.GetVideoFiles(directoryInfo.FullName); - var rarFiles = _diskProvider.GetFiles(directoryInfo.FullName, SearchOption.AllDirectories).Where(f => Path.GetExtension(f) == ".rar"); + var rarFiles = _diskProvider.GetFiles(directoryInfo.FullName, SearchOption.AllDirectories).Where(f => Path.GetExtension(f).Equals(".rar", StringComparison.OrdinalIgnoreCase)); foreach (var videoFile in videoFiles) { @@ -113,10 +114,7 @@ namespace NzbDrone.Core.MediaFiles return false; } - var size = _diskProvider.GetFileSize(videoFile); - var quality = QualityParser.ParseQuality(videoFile); - - if (!_detectSample.IsSample(series, quality, videoFile, size, episodeParseResult.IsPossibleSpecialEpisode)) + if (_detectSample.IsSample(series, videoFile, episodeParseResult.IsPossibleSpecialEpisode) != DetectSampleResult.Sample) { _logger.Warn("Non-sample file detected: [{0}]", videoFile); return false; @@ -166,7 +164,7 @@ namespace NzbDrone.Core.MediaFiles _logger.Debug("{0} folder quality: {1}", cleanedUpName, folderInfo.Quality); } - var videoFiles = _diskScanService.GetVideoFiles(directoryInfo.FullName); + var videoFiles = _diskScanService.FilterFiles(directoryInfo.FullName, _diskScanService.GetVideoFiles(directoryInfo.FullName)); if (downloadClientItem == null) { @@ -182,10 +180,15 @@ namespace NzbDrone.Core.MediaFiles } } - var decisions = _importDecisionMaker.GetImportDecisions(videoFiles.ToList(), series, folderInfo, true); + var decisions = _importDecisionMaker.GetImportDecisions(videoFiles.ToList(), series, downloadClientItem, folderInfo, true); var importResults = _importApprovedEpisodes.Import(decisions, true, downloadClientItem, importMode); - if ((downloadClientItem == null || !downloadClientItem.IsReadOnly) && + if (importMode == ImportMode.Auto) + { + importMode = (downloadClientItem == null || downloadClientItem.CanMoveFiles) ? ImportMode.Move : ImportMode.Copy; + } + + if (importMode == ImportMode.Move && importResults.Any(i => i.Result == ImportResultType.Imported) && ShouldDeleteFolder(directoryInfo, series)) { @@ -236,7 +239,7 @@ namespace NzbDrone.Core.MediaFiles } } - var decisions = _importDecisionMaker.GetImportDecisions(new List() { fileInfo.FullName }, series, null, true); + var decisions = _importDecisionMaker.GetImportDecisions(new List() { fileInfo.FullName }, series, downloadClientItem, null, true); return _importApprovedEpisodes.Import(decisions, true, downloadClientItem, importMode); } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs b/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs index ecce449b4..267d5aa58 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs @@ -5,6 +5,7 @@ using NzbDrone.Core.Datastore; using NzbDrone.Core.Qualities; using NzbDrone.Core.Tv; using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Common.Extensions; namespace NzbDrone.Core.MediaFiles { @@ -27,5 +28,20 @@ namespace NzbDrone.Core.MediaFiles { return string.Format("[{0}] {1}", Id, RelativePath); } + + public string GetSceneOrFileName() + { + if (SceneName.IsNotNullOrWhiteSpace()) + { + return SceneName; + } + + if (RelativePath.IsNotNullOrWhiteSpace()) + { + return System.IO.Path.GetFileName(RelativePath); + } + + return string.Empty; + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs index 27492d56a..9c5e185b0 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs @@ -1,16 +1,14 @@ using System; -using System.Collections.Generic; using System.IO; using NLog; using NzbDrone.Core.MediaFiles.MediaInfo; -using NzbDrone.Core.Qualities; using NzbDrone.Core.Tv; namespace NzbDrone.Core.MediaFiles.EpisodeImport { public interface IDetectSample { - bool IsSample(Series series, QualityModel quality, string path, long size, bool isSpecial); + DetectSampleResult IsSample(Series series, string path, bool isSpecial); } public class DetectSample : IDetectSample @@ -18,22 +16,18 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport private readonly IVideoFileInfoReader _videoFileInfoReader; private readonly Logger _logger; - private static List _largeSampleSizeQualities = new List { Quality.HDTV1080p, Quality.WEBDL1080p, Quality.Bluray1080p }; - public DetectSample(IVideoFileInfoReader videoFileInfoReader, Logger logger) { _videoFileInfoReader = videoFileInfoReader; _logger = logger; } - public static long SampleSizeLimit => 70.Megabytes(); - - public bool IsSample(Series series, QualityModel quality, string path, long size, bool isSpecial) + public DetectSampleResult IsSample(Series series, string path, bool isSpecial) { if (isSpecial) { _logger.Debug("Special, skipping sample check"); - return false; + return DetectSampleResult.NotSample; } var extension = Path.GetExtension(path); @@ -41,65 +35,49 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport if (extension != null && extension.Equals(".flv", StringComparison.InvariantCultureIgnoreCase)) { _logger.Debug("Skipping sample check for .flv file"); - return false; + return DetectSampleResult.NotSample; } if (extension != null && extension.Equals(".strm", StringComparison.InvariantCultureIgnoreCase)) { _logger.Debug("Skipping sample check for .strm file"); - return false; + return DetectSampleResult.NotSample; } - try + var runTime = _videoFileInfoReader.GetRunTime(path); + + if (!runTime.HasValue) { - var runTime = _videoFileInfoReader.GetRunTime(path); - var minimumRuntime = GetMinimumAllowedRuntime(series); - - if (runTime.TotalMinutes.Equals(0)) - { - _logger.Error("[{0}] has a runtime of 0, is it a valid video file?", path); - return true; - } - - if (runTime.TotalSeconds < minimumRuntime) - { - _logger.Debug("[{0}] appears to be a sample. Runtime: {1} seconds. Expected at least: {2} seconds", path, runTime, minimumRuntime); - return true; - } + _logger.Error("Failed to get runtime from the file, make sure mediainfo is available"); + return DetectSampleResult.Indeterminate; } - catch (DllNotFoundException) - { - _logger.Debug("Falling back to file size detection"); + var minimumRuntime = GetMinimumAllowedRuntime(series); - return CheckSize(size, quality); + if (runTime.Value.TotalMinutes.Equals(0)) + { + _logger.Error("[{0}] has a runtime of 0, is it a valid video file?", path); + return DetectSampleResult.Sample; + } + + if (runTime.Value.TotalSeconds < minimumRuntime) + { + _logger.Debug("[{0}] appears to be a sample. Runtime: {1} seconds. Expected at least: {2} seconds", path, runTime, minimumRuntime); + return DetectSampleResult.Sample; } _logger.Debug("Runtime is over 90 seconds"); - return false; - } - - private bool CheckSize(long size, QualityModel quality) - { - { - if (size < SampleSizeLimit * 2) - { - _logger.Debug("1080p file is less than sample limit"); - return true; - } - } - - if (size < SampleSizeLimit) - { - _logger.Debug("File is less than sample limit"); - return true; - } - - return false; + return DetectSampleResult.NotSample; } private int GetMinimumAllowedRuntime(Series series) { + //Anime short - 15 seconds + if (series.Runtime <= 3) + { + return 15; + } + //Webisodes - 90 seconds if (series.Runtime <= 10) { diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSampleResult.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSampleResult.cs new file mode 100644 index 000000000..81266a717 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSampleResult.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.MediaFiles.EpisodeImport +{ + public enum DetectSampleResult + { + Indeterminate, + Sample, + NotSample + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs index 86abb87b7..9778664cb 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs @@ -1,10 +1,11 @@ using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.EpisodeImport { public interface IImportDecisionEngineSpecification { - Decision IsSatisfiedBy(LocalEpisode localEpisode); + Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem); } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs index cdfd289db..4694803e3 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs @@ -90,7 +90,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport { default: case ImportMode.Auto: - copyOnly = downloadClientItem != null && downloadClientItem.IsReadOnly; + copyOnly = downloadClientItem != null && !downloadClientItem.CanMoveFiles; break; case ImportMode.Move: copyOnly = false; @@ -120,19 +120,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport _extraService.ImportExtraFiles(localEpisode, episodeFile, copyOnly); } - if (downloadClientItem != null) - { - _eventAggregator.PublishEvent(new EpisodeImportedEvent(localEpisode, episodeFile, newDownload, downloadClientItem.DownloadClient, downloadClientItem.DownloadId, downloadClientItem.IsReadOnly)); - } - else - { - _eventAggregator.PublishEvent(new EpisodeImportedEvent(localEpisode, episodeFile, newDownload)); - } - - if (newDownload) - { - _eventAggregator.PublishEvent(new EpisodeDownloadedEvent(localEpisode, episodeFile, oldFiles)); - } + _eventAggregator.PublishEvent(new EpisodeImportedEvent(localEpisode, episodeFile, oldFiles, newDownload, downloadClientItem)); } catch (Exception e) { diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs index 764e1b88f..859c8111d 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs @@ -6,6 +6,7 @@ using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; @@ -18,7 +19,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport public interface IMakeImportDecision { List GetImportDecisions(List videoFiles, Series series); - List GetImportDecisions(List videoFiles, Series series, ParsedEpisodeInfo folderInfo, bool sceneSource); + List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource); } public class ImportDecisionMaker : IMakeImportDecision @@ -50,10 +51,10 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport public List GetImportDecisions(List videoFiles, Series series) { - return GetImportDecisions(videoFiles, series, null, false); + return GetImportDecisions(videoFiles, series, null, null, false); } - public List GetImportDecisions(List videoFiles, Series series, ParsedEpisodeInfo folderInfo, bool sceneSource) + public List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource) { var newFiles = _mediaFileService.FilterExistingFiles(videoFiles.ToList(), series); @@ -64,13 +65,13 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport foreach (var file in newFiles) { - decisions.AddIfNotNull(GetDecision(file, series, folderInfo, sceneSource, shouldUseFolderName)); + decisions.AddIfNotNull(GetDecision(file, series, downloadClientItem, folderInfo, sceneSource, shouldUseFolderName)); } return decisions; } - private ImportDecision GetDecision(string file, Series series, ParsedEpisodeInfo folderInfo, bool sceneSource, bool shouldUseFolderName) + private ImportDecision GetDecision(string file, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource, bool shouldUseFolderName) { ImportDecision decision = null; @@ -97,7 +98,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport } else { - decision = GetDecision(localEpisode); + decision = GetDecision(localEpisode, downloadClientItem); } } @@ -117,22 +118,27 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport decision = new ImportDecision(localEpisode, new Rejection("Unexpected error processing file")); } + if (decision == null) + { + _logger.Error("Unable to make a decision on {0}", file); + } + return decision; } - private ImportDecision GetDecision(LocalEpisode localEpisode) + private ImportDecision GetDecision(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { - var reasons = _specifications.Select(c => EvaluateSpec(c, localEpisode)) + var reasons = _specifications.Select(c => EvaluateSpec(c, localEpisode, downloadClientItem)) .Where(c => c != null); return new ImportDecision(localEpisode, reasons.ToArray()); } - private Rejection EvaluateSpec(IImportDecisionEngineSpecification spec, LocalEpisode localEpisode) + private Rejection EvaluateSpec(IImportDecisionEngineSpecification spec, LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { try { - var result = spec.IsSatisfiedBy(localEpisode); + var result = spec.IsSatisfiedBy(localEpisode, downloadClientItem); if (!result.Accepted) { @@ -164,11 +170,9 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return videoFiles.Count(file => { - var size = _diskProvider.GetFileSize(file); - var fileQuality = QualityParser.ParseQuality(file); - var sample = _detectSample.IsSample(series, GetQuality(folderInfo, fileQuality, series), file, size, folderInfo.IsPossibleSpecialEpisode); + var sample = _detectSample.IsSample(series, file, folderInfo.IsPossibleSpecialEpisode); - if (sample) + if (sample == DetectSampleResult.Sample) { return false; } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs index d85a2e119..ca7484ebc 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs @@ -94,25 +94,31 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual private List ProcessFolder(string folder, string downloadId) { + DownloadClientItem downloadClientItem = null; var directoryInfo = new DirectoryInfo(folder); var series = _parsingService.GetSeries(directoryInfo.Name); - if (series == null && downloadId.IsNotNullOrWhiteSpace()) + if (downloadId.IsNotNullOrWhiteSpace()) { var trackedDownload = _trackedDownloadService.Find(downloadId); - series = trackedDownload.RemoteEpisode.Series; + downloadClientItem = trackedDownload.DownloadItem; + + if (series == null) + { + series = trackedDownload.RemoteEpisode.Series; + } } if (series == null) { - var files = _diskScanService.GetVideoFiles(folder); + var files = _diskScanService.FilterFiles(folder, _diskScanService.GetVideoFiles(folder)); return files.Select(file => ProcessFile(file, downloadId, folder)).Where(i => i != null).ToList(); } var folderInfo = Parser.Parser.ParseTitle(directoryInfo.Name); var seriesFiles = _diskScanService.GetVideoFiles(folder).ToList(); - var decisions = _importDecisionMaker.GetImportDecisions(seriesFiles, series, folderInfo, SceneSource(series, folder)); + var decisions = _importDecisionMaker.GetImportDecisions(seriesFiles, series, downloadClientItem, folderInfo, SceneSource(series, folder)); return decisions.Select(decision => MapItem(decision, folder, downloadId)).ToList(); } @@ -124,8 +130,8 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual folder = new FileInfo(file).Directory.FullName; } + DownloadClientItem downloadClientItem = null; var relativeFile = folder.GetRelativePath(file); - var series = _parsingService.GetSeries(relativeFile.Split('\\', '/')[0]); if (series == null) @@ -133,10 +139,15 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual series = _parsingService.GetSeries(relativeFile); } - if (series == null && downloadId.IsNotNullOrWhiteSpace()) + if (downloadId.IsNotNullOrWhiteSpace()) { var trackedDownload = _trackedDownloadService.Find(downloadId); - series = trackedDownload.RemoteEpisode.Series; + downloadClientItem = trackedDownload.DownloadItem; + + if (series == null) + { + series = trackedDownload.RemoteEpisode.Series; + } } if (series == null) @@ -150,9 +161,19 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual } var importDecisions = _importDecisionMaker.GetImportDecisions(new List {file}, - series, null, SceneSource(series, folder)); + series, downloadClientItem, null, SceneSource(series, folder)); - return importDecisions.Any() ? MapItem(importDecisions.First(), folder, downloadId) : null; + return importDecisions.Any() ? MapItem(importDecisions.First(), folder, downloadId) : new ManualImportItem + { + DownloadId = downloadId, + Path = file, + RelativePath = folder.GetRelativePath(file), + Name = Path.GetFileNameWithoutExtension(file), + Rejections = new List + { + new Rejection("Unable to process file") + } + }; } private bool SceneSource(Series series, string folder) @@ -251,7 +272,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual { if (_downloadedEpisodesImportService.ShouldDeleteFolder( new DirectoryInfo(trackedDownload.DownloadItem.OutputPath.FullPath), - trackedDownload.RemoteEpisode.Series) && !trackedDownload.DownloadItem.IsReadOnly) + trackedDownload.RemoteEpisode.Series) && trackedDownload.DownloadItem.CanMoveFiles) { _diskProvider.DeleteFolder(trackedDownload.DownloadItem.OutputPath.FullPath, true); } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs index 490bdb941..d1edad4e6 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs @@ -4,6 +4,7 @@ using NLog; using NzbDrone.Common.Disk; using NzbDrone.Core.Configuration; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications @@ -21,7 +22,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { if (_configService.SkipFreeSpaceCheckWhenImporting) { diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs index 7397c13e7..d307121eb 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs @@ -1,5 +1,6 @@ using NLog; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications @@ -13,7 +14,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { if (localEpisode.ParsedEpisodeInfo.FullSeason) { diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualitySpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualitySpecification.cs new file mode 100644 index 000000000..1ea418d74 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualitySpecification.cs @@ -0,0 +1,60 @@ +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.History; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications +{ + public class GrabbedReleaseQualitySpecification : IImportDecisionEngineSpecification + { + private readonly IHistoryService _historyService; + private readonly Logger _logger; + + public GrabbedReleaseQualitySpecification(IHistoryService historyService, Logger logger) + { + _historyService = historyService; + _logger = logger; + } + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) + { + if (downloadClientItem == null) + { + _logger.Debug("No download client item provided, skipping."); + return Decision.Accept(); + } + + var grabbedHistory = _historyService.FindByDownloadId(downloadClientItem.DownloadId) + .Where(h => h.EventType == HistoryEventType.Grabbed) + .ToList(); + + if (grabbedHistory.Empty()) + { + _logger.Debug("No grabbed history for this download client item"); + return Decision.Accept(); + } + + var parsedReleaseName = Parser.Parser.ParseTitle(grabbedHistory.First().SourceTitle); + + if (parsedReleaseName != null && parsedReleaseName.FullSeason) + { + _logger.Debug("File is part of a season pack, skipping."); + return Decision.Accept(); + } + + foreach (var item in grabbedHistory) + { + if (item.Quality.Quality != Quality.Unknown && item.Quality != localEpisode.Quality) + { + _logger.Debug("Quality for grabbed release ({0}) does not match the quality of the file ({1})", item.Quality, localEpisode.Quality); + return Decision.Reject("File quality does not match quality of the grabbed release"); + } + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs index 79ef96f88..8d90629d3 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs @@ -2,6 +2,7 @@ using System.Linq; using NLog; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications @@ -14,7 +15,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications { _logger = logger; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { if (localEpisode.ExistingFile) { diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs index c7b61d802..ea3848d39 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs @@ -1,5 +1,6 @@ using NLog; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications @@ -16,7 +17,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { if (localEpisode.ExistingFile) { @@ -25,16 +26,19 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications } var sample = _detectSample.IsSample(localEpisode.Series, - localEpisode.Quality, localEpisode.Path, - localEpisode.Size, localEpisode.IsSpecial); - if (sample) + if (sample == DetectSampleResult.Sample) { return Decision.Reject("Sample"); } + else if (sample == DetectSampleResult.Indeterminate) + { + return Decision.Reject("Unable to determine if file is a sample"); + } + return Decision.Accept(); } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs index 2260ed71a..5b42a609e 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs @@ -5,6 +5,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications @@ -22,7 +23,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { if (localEpisode.ExistingFile) { diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs index ee6c02c53..b13b0f552 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs @@ -1,5 +1,6 @@ -using NLog; +using NLog; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications @@ -17,7 +18,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications public RejectionType Type => RejectionType.Permanent; - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { if (_sameEpisodesSpecification.IsSatisfiedBy(localEpisode.Episodes)) { diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameFileSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameFileSpecification.cs new file mode 100644 index 000000000..9876ff492 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameFileSpecification.cs @@ -0,0 +1,42 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications +{ + public class SameFileSpecification : IImportDecisionEngineSpecification + { + private readonly Logger _logger; + + public SameFileSpecification(Logger logger) + { + _logger = logger; + } + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) + { + var episodeFiles = localEpisode.Episodes.Where(e => e.EpisodeFileId != 0).Select(e => e.EpisodeFile).ToList(); + + if (episodeFiles.Count == 0) + { + _logger.Debug("No existing episode file, skipping"); + return Decision.Accept(); + } + + if (episodeFiles.Count > 1) + { + _logger.Debug("More than one existing episode file, skipping."); + return Decision.Accept(); + } + + if (episodeFiles.First().Value.Size == localEpisode.Size) + { + _logger.Debug("'{0}' Has the same filesize as existing file", localEpisode.Path); + return Decision.Reject("Has the same filesize as existing file"); + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs index ce65eb304..ede9cee58 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs @@ -1,6 +1,7 @@ using System.Linq; using NLog; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications { @@ -13,7 +14,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { if (localEpisode.ExistingFile) { diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs index 3d07306af..a418bfa76 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs @@ -1,6 +1,7 @@ using System.Linq; using NLog; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; @@ -15,7 +16,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { var qualityComparer = new QualityModelComparer(localEpisode.Series.Profile); if (localEpisode.Episodes.Any(e => e.EpisodeFileId != 0 && qualityComparer.Compare(e.EpisodeFile.Value.Quality, localEpisode.Quality) > 0)) diff --git a/src/NzbDrone.Core/MediaFiles/Events/EpisodeDownloadedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/EpisodeDownloadedEvent.cs deleted file mode 100644 index af22b63fb..000000000 --- a/src/NzbDrone.Core/MediaFiles/Events/EpisodeDownloadedEvent.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Common.Messaging; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.MediaFiles.Events -{ - public class EpisodeDownloadedEvent : IEvent - { - public LocalEpisode Episode { get; private set; } - public EpisodeFile EpisodeFile { get; private set; } - public List OldFiles { get; private set; } - - public EpisodeDownloadedEvent(LocalEpisode episode, EpisodeFile episodeFile, List oldFiles) - { - Episode = episode; - EpisodeFile = episodeFile; - OldFiles = oldFiles; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/EpisodeFileRenamedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/EpisodeFileRenamedEvent.cs new file mode 100644 index 000000000..98708ce5e --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/EpisodeFileRenamedEvent.cs @@ -0,0 +1,19 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class EpisodeFileRenamedEvent : IEvent + { + public Series Series { get; private set; } + public EpisodeFile EpisodeFile { get; private set; } + public string OriginalPath { get; private set; } + + public EpisodeFileRenamedEvent(Series series, EpisodeFile episodeFile, string originalPath) + { + Series = series; + EpisodeFile = episodeFile; + OriginalPath = originalPath; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportedEvent.cs index 518132857..7c870666b 100644 --- a/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportedEvent.cs +++ b/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportedEvent.cs @@ -1,4 +1,6 @@ -using NzbDrone.Common.Messaging; +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.Events @@ -7,26 +9,23 @@ namespace NzbDrone.Core.MediaFiles.Events { public LocalEpisode EpisodeInfo { get; private set; } public EpisodeFile ImportedEpisode { get; private set; } + public List OldFiles { get; private set; } public bool NewDownload { get; private set; } public string DownloadClient { get; private set; } public string DownloadId { get; private set; } - public bool IsReadOnly { get; set; } - public EpisodeImportedEvent(LocalEpisode episodeInfo, EpisodeFile importedEpisode, bool newDownload) + public EpisodeImportedEvent(LocalEpisode episodeInfo, EpisodeFile importedEpisode, List oldFiles, bool newDownload, DownloadClientItem downloadClientItem) { EpisodeInfo = episodeInfo; ImportedEpisode = importedEpisode; + OldFiles = oldFiles; NewDownload = newDownload; - } - public EpisodeImportedEvent(LocalEpisode episodeInfo, EpisodeFile importedEpisode, bool newDownload, string downloadClient, string downloadId, bool isReadOnly) - { - EpisodeInfo = episodeInfo; - ImportedEpisode = importedEpisode; - NewDownload = newDownload; - DownloadClient = downloadClient; - DownloadId = downloadId; - IsReadOnly = isReadOnly; + if (downloadClientItem != null) + { + DownloadClient = downloadClientItem.DownloadClient; + DownloadId = downloadClientItem.DownloadId; + } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs new file mode 100644 index 000000000..65dd2369c --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; +using System.Net; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Tv.Events; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IDeleteMediaFiles + { + void DeleteEpisodeFile(Series series, EpisodeFile episodeFile); + } + + public class MediaFileDeletionService : IDeleteMediaFiles, IHandleAsync + { + private readonly IDiskProvider _diskProvider; + private readonly IRecycleBinProvider _recycleBinProvider; + private readonly IMediaFileService _mediaFileService; + private readonly Logger _logger; + + public MediaFileDeletionService(IDiskProvider diskProvider, + IRecycleBinProvider recycleBinProvider, + IMediaFileService mediaFileService, + Logger logger) + { + _diskProvider = diskProvider; + _recycleBinProvider = recycleBinProvider; + _mediaFileService = mediaFileService; + _logger = logger; + } + + public void DeleteEpisodeFile(Series series, EpisodeFile episodeFile) + { + var fullPath = Path.Combine(series.Path, episodeFile.RelativePath); + var rootFolder = _diskProvider.GetParentFolder(series.Path); + + if (!_diskProvider.FolderExists(rootFolder)) + { + throw new NzbDroneClientException(HttpStatusCode.Conflict, "Series' root folder ({0}) doesn't exist.", rootFolder); + } + + if (_diskProvider.GetDirectories(rootFolder).Empty()) + { + throw new NzbDroneClientException(HttpStatusCode.Conflict, "Series' root folder ({0}) is empty.", rootFolder); + } + + if (_diskProvider.FolderExists(series.Path) && _diskProvider.FileExists(fullPath)) + { + _logger.Info("Deleting episode file: {0}", fullPath); + + var subfolder = _diskProvider.GetParentFolder(series.Path).GetRelativePath(_diskProvider.GetParentFolder(fullPath)); + + try + { + _recycleBinProvider.DeleteFile(fullPath, subfolder); + } + catch (Exception e) + { + _logger.Error(e, "Unable to delete episode file"); + throw new NzbDroneClientException(HttpStatusCode.InternalServerError, "Unable to delete episode file"); + } + } + + // Delete the episode file from the database to clean it up even if the file was already deleted + _mediaFileService.Delete(episodeFile, DeleteMediaFileReason.Manual); + } + + public void HandleAsync(SeriesDeletedEvent message) + { + if (message.DeleteFiles) + { + if (_diskProvider.FolderExists(message.Series.Path)) + { + _recycleBinProvider.DeleteFolder(message.Series.Path); + } + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs b/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs index 6a951a3b9..e62e046d3 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using NzbDrone.Core.Qualities; @@ -10,7 +11,7 @@ namespace NzbDrone.Core.MediaFiles static MediaFileExtensions() { - _fileExtensions = new Dictionary + _fileExtensions = new Dictionary(StringComparer.OrdinalIgnoreCase) { //Unknown { ".webm", Quality.Unknown }, @@ -70,7 +71,7 @@ namespace NzbDrone.Core.MediaFiles }; } - public static HashSet Extensions => new HashSet(_fileExtensions.Keys); + public static HashSet Extensions => new HashSet(_fileExtensions.Keys, StringComparer.OrdinalIgnoreCase); public static Quality GetQualityForExtension(string extension) { diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs new file mode 100644 index 000000000..4932dd260 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs @@ -0,0 +1,351 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using NLog; +using NLog.Fluent; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Common.Instrumentation.Extensions; + +namespace NzbDrone.Core.MediaFiles.MediaInfo +{ + public static class MediaInfoFormatter + { + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(MediaInfoFormatter)); + + public static decimal FormatAudioChannels(MediaInfoModel mediaInfo) + { + var audioChannelPositions = mediaInfo.AudioChannelPositions; + var audioChannelPositionsText = mediaInfo.AudioChannelPositionsText; + var audioChannels = mediaInfo.AudioChannels; + + if (audioChannelPositions.IsNullOrWhiteSpace()) + { + if (audioChannelPositionsText.IsNullOrWhiteSpace()) + { + if (mediaInfo.SchemaRevision >= 3) + { + return audioChannels; + } + + return 0; + } + + return mediaInfo.AudioChannelPositionsText.ContainsIgnoreCase("LFE") ? audioChannels - 1 + 0.1m : audioChannels; + } + + return audioChannelPositions.Replace("Object Based / ", "") + .Split(new string[] { " / " }, StringSplitOptions.None) + .First() + .Split('/') + .Sum(s => decimal.Parse(s, CultureInfo.InvariantCulture)); + } + + public static string FormatAudioCodec(MediaInfoModel mediaInfo, string sceneName) + { + if (mediaInfo.AudioCodecID == null) + { + return FormatAudioCodecLegacy(mediaInfo, sceneName); + } + + var audioFormat = mediaInfo.AudioFormat; + var audioCodecID = mediaInfo.AudioCodecID ?? string.Empty; + var audioProfile = mediaInfo.AudioProfile ?? string.Empty; + var audioCodecLibrary = mediaInfo.AudioCodecLibrary ?? string.Empty; + + if (audioFormat.IsNullOrWhiteSpace()) + { + return string.Empty; + } + + if (audioFormat.EqualsIgnoreCase("AC-3")) + { + return "AC3"; + } + + if (audioFormat.EqualsIgnoreCase("E-AC-3")) + { + return "EAC3"; + } + + if (audioFormat.EqualsIgnoreCase("AAC")) + { + if (audioCodecID == "A_AAC/MPEG4/LC/SBR") + { + return "HE-AAC"; + } + + return "AAC"; + } + + if (audioFormat.EqualsIgnoreCase("DTS")) + { + return "DTS"; + } + + if (audioFormat.EqualsIgnoreCase("FLAC")) + { + return "FLAC"; + } + + if (audioFormat.EqualsIgnoreCase("MPEG Audio")) + { + if (mediaInfo.AudioCodecID == "55" || mediaInfo.AudioCodecID == "A_MPEG/L3" || mediaInfo.AudioProfile == "Layer 3") + { + return "MP3"; + } + + if (mediaInfo.AudioCodecID == "A_MPEG/L2" || mediaInfo.AudioProfile == "Layer 2") + { + return "MP2"; + } + } + + if (audioFormat.EqualsIgnoreCase("PCM")) + { + return "PCM"; + } + + if (audioFormat.EqualsIgnoreCase("TrueHD")) + { + return "TrueHD"; + } + + if (audioFormat.EqualsIgnoreCase("Vorbis")) + { + return "Vorbis"; + } + + if (audioFormat == "WMA") + { + return "WMA"; + } + + Logger.Debug() + .Message("Unknown audio format: '{0}' in '{1}'.", string.Join(", ", audioFormat, audioCodecID, audioProfile, audioCodecLibrary), sceneName) + .WriteSentryWarn("UnknownAudioFormat", mediaInfo.ContainerFormat, audioFormat, audioCodecID) + .Write(); + + return audioFormat; + } + + public static string FormatAudioCodecLegacy(MediaInfoModel mediaInfo, string sceneName) + { + var audioFormat = mediaInfo.AudioFormat; + + if (audioFormat.IsNullOrWhiteSpace()) + { + return audioFormat; + } + + if (audioFormat.EqualsIgnoreCase("AC-3")) + { + return "AC3"; + } + + if (audioFormat.EqualsIgnoreCase("E-AC-3")) + { + return "EAC3"; + } + + if (audioFormat.EqualsIgnoreCase("AAC")) + { + return "AAC"; + } + + if (audioFormat.EqualsIgnoreCase("MPEG Audio") && mediaInfo.AudioProfile == "Layer 3") + { + return "MP3"; + } + + if (audioFormat.EqualsIgnoreCase("DTS")) + { + return "DTS"; + } + + if (audioFormat.EqualsIgnoreCase("TrueHD")) + { + return "TrueHD"; + } + + if (audioFormat.EqualsIgnoreCase("FLAC")) + { + return "FLAC"; + } + + if (audioFormat.EqualsIgnoreCase("Vorbis")) + { + return "Vorbis"; + } + + if (audioFormat.EqualsIgnoreCase("Opus")) + { + return "Opus"; + } + + return audioFormat; + } + + public static string FormatVideoCodec(MediaInfoModel mediaInfo, string sceneName) + { + if (mediaInfo.VideoFormat == null) + { + return FormatVideoCodecLegacy(mediaInfo, sceneName); + } + + var videoFormat = mediaInfo.VideoFormat; + var videoCodecID = mediaInfo.VideoCodecID ?? string.Empty; + var videoProfile = mediaInfo.VideoProfile ?? string.Empty; + var videoCodecLibrary = mediaInfo.VideoCodecLibrary ?? string.Empty; + + if (videoFormat.IsNullOrWhiteSpace()) + { + return videoFormat; + } + + if (videoFormat == "AVC" || videoFormat == "V.MPEG4/ISO/AVC") + { + if (videoCodecLibrary.StartsWithIgnoreCase("x264")) + { + return "x264"; + } + + return GetSceneNameMatch(sceneName, "AVC", "h264"); + } + + if (videoFormat.EqualsIgnoreCase("DivX")) + { + return "DivX"; + } + + if (videoFormat == "HEVC") + { + if (videoCodecLibrary.StartsWithIgnoreCase("x265")) + { + return "x265"; + } + + return GetSceneNameMatch(sceneName, "HEVC", "h265"); + } + + if (videoFormat == "MPEG Video") + { + if (videoCodecID == "2" || videoCodecID == "V_MPEG2") + { + return "MPEG2"; + } + + if (videoCodecID.IsNullOrWhiteSpace()) + { + return "MPEG"; + } + } + + if (videoFormat == "MPEG-2 Video") + { + return "MPEG2"; + } + + if (videoFormat == "MPEG-4 Visual") + { + if (videoCodecID.ContainsIgnoreCase("XVID")) + { + return "XviD"; + } + + if (videoCodecID.ContainsIgnoreCase("DIV3") || + videoCodecID.ContainsIgnoreCase("DIVX") || + videoCodecID.ContainsIgnoreCase("DX50")) + { + return "DivX"; + } + } + + if (videoFormat == "VC-1") + { + return "VC1"; + } + + if (videoFormat == "WMV2") + { + return "WMV"; + } + + if (videoFormat.EqualsIgnoreCase("XviD")) + { + return "XviD"; + } + + Logger.Debug() + .Message("Unknown video format: '{0}' in '{1}'.", string.Join(", ", videoFormat, videoCodecID, videoProfile, videoCodecLibrary), sceneName) + .WriteSentryWarn("UnknownVideoFormat", mediaInfo.ContainerFormat, videoFormat, videoCodecID) + .Write(); + + return videoFormat; + } + + public static string FormatVideoCodecLegacy(MediaInfoModel mediaInfo, string sceneName) + { + var videoCodec = mediaInfo.VideoCodec; + + if (videoCodec.IsNullOrWhiteSpace()) + { + return videoCodec; + } + + if (videoCodec == "AVC") + { + return GetSceneNameMatch(sceneName, "AVC", "h264", "x264"); + } + + if (videoCodec == "V_MPEGH/ISO/HEVC" || videoCodec == "HEVC") + { + return GetSceneNameMatch(sceneName, "HEVC", "h265", "x265"); + } + + if (videoCodec == "MPEG-2 Video") + { + return "MPEG2"; + } + + if (videoCodec == "MPEG-4 Visual") + { + return GetSceneNameMatch(sceneName, "DivX", "XviD"); + } + + if (videoCodec.StartsWithIgnoreCase("XviD")) + { + return "XviD"; + } + + if (videoCodec.StartsWithIgnoreCase("DivX")) + { + return "DivX"; + } + + if (videoCodec.EqualsIgnoreCase("VC-1")) + { + return "VC1"; + } + + return videoCodec; + } + + private static string GetSceneNameMatch(string sceneName, params string[] tokens) + { + sceneName = sceneName.IsNotNullOrWhiteSpace() ? Path.GetFileNameWithoutExtension(sceneName) : string.Empty; + + foreach (var token in tokens) + { + if (sceneName.ContainsIgnoreCase(token)) + { + return token; + } + } + + // Last token is the default. + return tokens.Last(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs index af02288e8..03f4ef3c5 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs @@ -9,12 +9,20 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo { public class MediaInfoModel : IEmbeddedDocument { + public string ContainerFormat { get; set; } + // Deprecated according to MediaInfo public string VideoCodec { get; set; } + public string VideoFormat { get; set; } + public string VideoCodecID { get; set; } + public string VideoProfile { get; set; } + public string VideoCodecLibrary { get; set; } public int VideoBitrate { get; set; } public int VideoBitDepth { get; set; } public int Width { get; set; } public int Height { get; set; } public string AudioFormat { get; set; } + public string AudioCodecID { get; set; } + public string AudioCodecLibrary { get; set; } public int AudioBitrate { get; set; } public TimeSpan RunTime { get; set; } public int AudioStreamCount { get; set; } @@ -27,33 +35,5 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo public string Subtitles { get; set; } public string ScanType { get; set; } public int SchemaRevision { get; set; } - - [JsonIgnore] - public decimal FormattedAudioChannels - { - get - { - if (AudioChannelPositions.IsNullOrWhiteSpace()) - { - if (AudioChannelPositionsText.IsNullOrWhiteSpace()) - { - if (SchemaRevision >= 3) - { - return AudioChannels; - } - - return 0; - } - - return AudioChannelPositionsText.ContainsIgnoreCase("LFE") ? AudioChannels - 1 + 0.1m : AudioChannels; - } - - return AudioChannelPositions.Replace("Object Based / ", "") - .Split(new string[] { " / " }, StringSplitOptions.None) - .First() - .Split('/') - .Sum(s => decimal.Parse(s, CultureInfo.InvariantCulture)); - } - } } } diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs index fb232f2f9..4630ea592 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs @@ -18,7 +18,8 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo private readonly IConfigService _configService; private readonly Logger _logger; - private const int CURRENT_MEDIA_INFO_SCHEMA_REVISION = 3; + public const int MINIMUM_MEDIA_INFO_SCHEMA_REVISION = 3; + public const int CURRENT_MEDIA_INFO_SCHEMA_REVISION = 4; public UpdateMediaInfoService(IDiskProvider diskProvider, IMediaFileService mediaFileService, @@ -65,7 +66,7 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo } var allMediaFiles = _mediaFileService.GetFilesBySeries(message.Series.Id); - var filteredMediaFiles = allMediaFiles.Where(c => c.MediaInfo == null || c.MediaInfo.SchemaRevision < CURRENT_MEDIA_INFO_SCHEMA_REVISION).ToList(); + var filteredMediaFiles = allMediaFiles.Where(c => c.MediaInfo == null || c.MediaInfo.SchemaRevision < MINIMUM_MEDIA_INFO_SCHEMA_REVISION).ToList(); UpdateMediaInfo(message.Series, filteredMediaFiles); } diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs index a1bf6aa86..b86e0e973 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs @@ -9,7 +9,7 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo public interface IVideoFileInfoReader { MediaInfoModel GetMediaInfo(string filename); - TimeSpan GetRunTime(string filename); + TimeSpan? GetRunTime(string filename); } public class VideoFileInfoReader : IVideoFileInfoReader @@ -104,54 +104,44 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo int.TryParse(mediaInfo.Get(StreamKind.Audio, 0, "PlayTime"), out audioRuntime); int.TryParse(mediaInfo.Get(StreamKind.General, 0, "PlayTime"), out generalRuntime); - string aBitRate = mediaInfo.Get(StreamKind.Audio, 0, "BitRate"); - int aBindex = aBitRate.IndexOf(" /", StringComparison.InvariantCultureIgnoreCase); - if (aBindex > 0) - { - aBitRate = aBitRate.Remove(aBindex); - } + string aBitRate = mediaInfo.Get(StreamKind.Audio, 0, "BitRate").Split(new string[] { " /" }, StringSplitOptions.None)[0].Trim(); int.TryParse(aBitRate, out audioBitRate); int.TryParse(mediaInfo.Get(StreamKind.Audio, 0, "StreamCount"), out streamCount); - string audioChannelsStr = mediaInfo.Get(StreamKind.Audio, 0, "Channel(s)"); - int aCindex = audioChannelsStr.IndexOf(" /", StringComparison.InvariantCultureIgnoreCase); - - if (aCindex > 0) - { - audioChannelsStr = audioChannelsStr.Remove(aCindex); - } + string audioChannelsStr = mediaInfo.Get(StreamKind.Audio, 0, "Channel(s)").Split(new string[] { " /" }, StringSplitOptions.None)[0].Trim(); var audioChannelPositions = mediaInfo.Get(StreamKind.Audio, 0, "ChannelPositions/String2"); var audioChannelPositionsText = mediaInfo.Get(StreamKind.Audio, 0, "ChannelPositions"); string audioLanguages = mediaInfo.Get(StreamKind.General, 0, "Audio_Language_List"); - string audioProfile = mediaInfo.Get(StreamKind.Audio, 0, "Format_Profile"); - int aPindex = audioProfile.IndexOf(" /", StringComparison.InvariantCultureIgnoreCase); - - if (aPindex > 0) - { - audioProfile = audioProfile.Remove(aPindex); - } + string videoProfile = mediaInfo.Get(StreamKind.Video, 0, "Format_Profile").Split(new string[] { " /" }, StringSplitOptions.None)[0].Trim(); + string audioProfile = mediaInfo.Get(StreamKind.Audio, 0, "Format_Profile").Split(new string[] { " /" }, StringSplitOptions.None)[0].Trim(); int.TryParse(audioChannelsStr, out audioChannels); var mediaInfoModel = new MediaInfoModel { - VideoCodec = mediaInfo.Get(StreamKind.Video, 0, "Codec/String"), + ContainerFormat = mediaInfo.Get(StreamKind.General, 0, "Format"), + VideoFormat = mediaInfo.Get(StreamKind.Video, 0, "Format"), + VideoCodecID = mediaInfo.Get(StreamKind.Video, 0, "CodecID"), + VideoProfile = videoProfile, + VideoCodecLibrary = mediaInfo.Get(StreamKind.Video, 0, "Encoded_Library"), VideoBitrate = videoBitRate, VideoBitDepth = videoBitDepth, Height = height, Width = width, AudioFormat = mediaInfo.Get(StreamKind.Audio, 0, "Format"), + AudioCodecID = mediaInfo.Get(StreamKind.Audio, 0, "CodecID"), + AudioProfile = audioProfile, + AudioCodecLibrary = mediaInfo.Get(StreamKind.Audio, 0, "Encoded_Library"), AudioBitrate = audioBitRate, RunTime = GetBestRuntime(audioRuntime, videoRuntime, generalRuntime), AudioStreamCount = streamCount, AudioChannels = audioChannels, AudioChannelPositions = audioChannelPositions, AudioChannelPositionsText = audioChannelPositionsText, - AudioProfile = audioProfile.Trim(), VideoFps = videoFrameRate, AudioLanguages = audioLanguages, Subtitles = subtitles, @@ -181,16 +171,11 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo return null; } - public TimeSpan GetRunTime(string filename) + public TimeSpan? GetRunTime(string filename) { var info = GetMediaInfo(filename); - if (info == null) - { - return new TimeSpan(); - } - - return info.RunTime; + return info?.RunTime; } private TimeSpan GetBestRuntime(int audio, int video, int general) diff --git a/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs b/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs index 540164a7c..9cac1ca35 100644 --- a/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs +++ b/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs @@ -20,7 +20,7 @@ namespace NzbDrone.Core.MediaFiles void Cleanup(); } - public class RecycleBinProvider : IHandleAsync, IExecute, IRecycleBinProvider + public class RecycleBinProvider : IExecute, IRecycleBinProvider { private readonly IDiskTransferService _diskTransferService; private readonly IDiskProvider _diskProvider; @@ -62,11 +62,7 @@ namespace NzbDrone.Core.MediaFiles _diskProvider.FolderSetLastWriteTime(destination, DateTime.UtcNow); foreach (var file in _diskProvider.GetFiles(destination, SearchOption.AllDirectories)) { - if (OsInfo.IsWindows) - { - //TODO: Better fix than this for non-Windows? - _diskProvider.FileSetLastWriteTime(file, DateTime.UtcNow); - } + SetLastWriteTime(file, DateTime.UtcNow); } _logger.Debug("Folder has been moved to the recycling bin: {0}", destination); @@ -123,12 +119,8 @@ namespace NzbDrone.Core.MediaFiles _logger.Error(e, "Unable to move '{0}' to the recycling bin: '{1}'", path, destination); throw; } - - //TODO: Better fix than this for non-Windows? - if (OsInfo.IsWindows) - { - _diskProvider.FileSetLastWriteTime(destination, DateTime.UtcNow); - } + + SetLastWriteTime(destination, DateTime.UtcNow); _logger.Debug("File has been moved to the recycling bin: {0}", destination); } @@ -192,14 +184,15 @@ namespace NzbDrone.Core.MediaFiles _logger.Debug("Recycling Bin has been cleaned up."); } - public void HandleAsync(SeriesDeletedEvent message) + private void SetLastWriteTime(string file, DateTime dateTime) { - if (message.DeleteFiles) + // Swallow any IOException that may be thrown due to "Invalid parameter" + try + { + _diskProvider.FileSetLastWriteTime(file, dateTime); + } + catch (IOException) { - if (_diskProvider.FolderExists(message.Series.Path)) - { - DeleteFolder(message.Series.Path); - } } } diff --git a/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs b/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs index 4cfe84b37..e98598d43 100644 --- a/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs @@ -125,6 +125,8 @@ namespace NzbDrone.Core.MediaFiles renamed.Add(episodeFile); _logger.Debug("Renamed episode file: {0}", episodeFile); + + _eventAggregator.PublishEvent(new EpisodeFileRenamedEvent(series, episodeFile, episodeFilePath)); } catch (SameFilenameException ex) { diff --git a/src/NzbDrone.Core/Messaging/Commands/CommandExecutor.cs b/src/NzbDrone.Core/Messaging/Commands/CommandExecutor.cs index 15843ef7b..9d7cf4a50 100644 --- a/src/NzbDrone.Core/Messaging/Commands/CommandExecutor.cs +++ b/src/NzbDrone.Core/Messaging/Commands/CommandExecutor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using NLog; using NzbDrone.Common; @@ -48,9 +48,13 @@ namespace NzbDrone.Core.Messaging.Commands } catch (ThreadAbortException ex) { - _logger.Error(ex); + _logger.Error(ex, "Thread aborted"); Thread.ResetAbort(); } + catch (OperationCanceledException ex) + { + _logger.Trace("Stopped one command execution pipeline"); + } catch (Exception ex) { _logger.Error(ex, "Unknown error in thread"); @@ -76,7 +80,7 @@ namespace NzbDrone.Core.Messaging.Commands handler.Execute(command); - _commandQueueManager.Complete(commandModel, command.CompletionMessage); + _commandQueueManager.Complete(commandModel, command.CompletionMessage ?? commandModel.Message); } catch (CommandFailedException ex) { diff --git a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs index d45547b8f..7b87af393 100644 --- a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs +++ b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs @@ -35,7 +35,7 @@ namespace NzbDrone.Core.Messaging.Commands private readonly Logger _logger; private readonly ICached _commandCache; - private readonly BlockingCollection _commandQueue; + private readonly BlockingCollection _commandQueue; public CommandQueueManager(ICommandRepository repo, IServiceFactory serviceFactory, diff --git a/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs b/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs index a66d22c2c..0111a7342 100644 --- a/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs +++ b/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs @@ -62,6 +62,17 @@ namespace NzbDrone.Core.Messaging.Events } } + foreach (var handler in _serviceFactory.BuildAll>()) + { + var handlerLocal = handler; + + _taskFactory.StartNew(() => + { + handlerLocal.HandleAsync(@event); + }, TaskCreationOptions.PreferFairness) + .LogExceptions(); + } + foreach (var handler in _serviceFactory.BuildAll>()) { var handlerLocal = handler; diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs index 69733ae54..6f183542b 100644 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs @@ -39,14 +39,23 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString()); environmentVariables.Add("Sonarr_Series_Title", series.Title); environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString()); + environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); + environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); environmentVariables.Add("Sonarr_Release_EpisodeCount", remoteEpisode.Episodes.Count.ToString()); environmentVariables.Add("Sonarr_Release_SeasonNumber", remoteEpisode.ParsedEpisodeInfo.SeasonNumber.ToString()); environmentVariables.Add("Sonarr_Release_EpisodeNumbers", string.Join(",", remoteEpisode.Episodes.Select(e => e.EpisodeNumber))); + environmentVariables.Add("Sonarr_Release_EpisodeAirDates", string.Join(",", remoteEpisode.Episodes.Select(e => e.AirDate))); + environmentVariables.Add("Sonarr_Release_EpisodeAirDatesUtc", string.Join(",", remoteEpisode.Episodes.Select(e => e.AirDateUtc))); + environmentVariables.Add("Sonarr_Release_EpisodeTitles", string.Join("|", remoteEpisode.Episodes.Select(e => e.Title))); environmentVariables.Add("Sonarr_Release_Title", remoteEpisode.Release.Title); environmentVariables.Add("Sonarr_Release_Indexer", remoteEpisode.Release.Indexer); environmentVariables.Add("Sonarr_Release_Size", remoteEpisode.Release.Size.ToString()); + environmentVariables.Add("Sonarr_Release_Quality", remoteEpisode.ParsedEpisodeInfo.Quality.Quality.Name); + environmentVariables.Add("Sonarr_Release_QualityVersion", remoteEpisode.ParsedEpisodeInfo.Quality.Revision.Version.ToString()); environmentVariables.Add("Sonarr_Release_ReleaseGroup", releaseGroup); + environmentVariables.Add("Sonarr_Download_Client", message.DownloadClient ?? string.Empty); + environmentVariables.Add("Sonarr_Download_Id", message.DownloadId ?? string.Empty); ExecuteScript(environmentVariables); } @@ -59,10 +68,13 @@ namespace NzbDrone.Core.Notifications.CustomScript var environmentVariables = new StringDictionary(); environmentVariables.Add("Sonarr_EventType", "Download"); + environmentVariables.Add("Sonarr_IsUpgrade", message.OldFiles.Any().ToString()); environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString()); environmentVariables.Add("Sonarr_Series_Title", series.Title); environmentVariables.Add("Sonarr_Series_Path", series.Path); environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString()); + environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); + environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_Id", episodeFile.Id.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_EpisodeCount", episodeFile.Episodes.Value.Count.ToString()); @@ -79,6 +91,14 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Sonarr_EpisodeFile_SceneName", episodeFile.SceneName ?? string.Empty); environmentVariables.Add("Sonarr_EpisodeFile_SourcePath", sourcePath); environmentVariables.Add("Sonarr_EpisodeFile_SourceFolder", Path.GetDirectoryName(sourcePath)); + environmentVariables.Add("Sonarr_Download_Client", message.DownloadClient ?? string.Empty); + environmentVariables.Add("Sonarr_Download_Id", message.DownloadId ?? string.Empty); + + if (message.OldFiles.Any()) + { + environmentVariables.Add("Sonarr_DeletedRelativePaths", string.Join("|", message.OldFiles.Select(e => e.RelativePath))); + environmentVariables.Add("Sonarr_DeletedPaths", string.Join("|", message.OldFiles.Select(e => Path.Combine(series.Path, e.RelativePath)))); + } ExecuteScript(environmentVariables); } @@ -92,6 +112,8 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Sonarr_Series_Title", series.Title); environmentVariables.Add("Sonarr_Series_Path", series.Path); environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString()); + environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); + environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); ExecuteScript(environmentVariables); diff --git a/src/NzbDrone.Core/Notifications/DownloadMessage.cs b/src/NzbDrone.Core/Notifications/DownloadMessage.cs index a16ecea80..fb4e7a8ba 100644 --- a/src/NzbDrone.Core/Notifications/DownloadMessage.cs +++ b/src/NzbDrone.Core/Notifications/DownloadMessage.cs @@ -11,6 +11,8 @@ namespace NzbDrone.Core.Notifications public EpisodeFile EpisodeFile { get; set; } public List OldFiles { get; set; } public string SourcePath { get; set; } + public string DownloadClient { get; set; } + public string DownloadId { get; set; } public override string ToString() { diff --git a/src/NzbDrone.Core/Notifications/GrabMessage.cs b/src/NzbDrone.Core/Notifications/GrabMessage.cs index e62dbe701..90688a2c2 100644 --- a/src/NzbDrone.Core/Notifications/GrabMessage.cs +++ b/src/NzbDrone.Core/Notifications/GrabMessage.cs @@ -9,7 +9,9 @@ namespace NzbDrone.Core.Notifications public string Message { get; set; } public Series Series { get; set; } public RemoteEpisode Episode { get; set; } - public QualityModel Quality { get; set; } + public QualityModel Quality { get; set; } + public string DownloadClient { get; set; } + public string DownloadId { get; set; } public override string ToString() { diff --git a/src/NzbDrone.Core/Notifications/NotificationService.cs b/src/NzbDrone.Core/Notifications/NotificationService.cs index 985126f19..6820aee8c 100644 --- a/src/NzbDrone.Core/Notifications/NotificationService.cs +++ b/src/NzbDrone.Core/Notifications/NotificationService.cs @@ -14,7 +14,7 @@ namespace NzbDrone.Core.Notifications { public class NotificationService : IHandle, - IHandle, + IHandle, IHandle { private readonly INotificationFactory _notificationFactory; @@ -95,7 +95,9 @@ namespace NzbDrone.Core.Notifications Message = GetMessage(message.Episode.Series, message.Episode.Episodes, message.Episode.ParsedEpisodeInfo.Quality), Series = message.Episode.Series, Quality = message.Episode.ParsedEpisodeInfo.Quality, - Episode = message.Episode + Episode = message.Episode, + DownloadClient = message.DownloadClient, + DownloadId = message.DownloadId }; foreach (var notification in _notificationFactory.OnGrabEnabled()) @@ -113,20 +115,29 @@ namespace NzbDrone.Core.Notifications } } - public void Handle(EpisodeDownloadedEvent message) + public void Handle(EpisodeImportedEvent message) { - var downloadMessage = new DownloadMessage(); - downloadMessage.Message = GetMessage(message.Episode.Series, message.Episode.Episodes, message.Episode.Quality); - downloadMessage.Series = message.Episode.Series; - downloadMessage.EpisodeFile = message.EpisodeFile; - downloadMessage.OldFiles = message.OldFiles; - downloadMessage.SourcePath = message.Episode.Path; + if (!message.NewDownload) + { + return; + } + + var downloadMessage = new DownloadMessage + { + Message = GetMessage(message.EpisodeInfo.Series, message.EpisodeInfo.Episodes, message.EpisodeInfo.Quality), + Series = message.EpisodeInfo.Series, + EpisodeFile = message.ImportedEpisode, + OldFiles = message.OldFiles, + SourcePath = message.EpisodeInfo.Path, + DownloadClient = message.DownloadClient, + DownloadId = message.DownloadId + }; foreach (var notification in _notificationFactory.OnDownloadEnabled()) { try { - if (ShouldHandleSeries(notification.Definition, message.Episode.Series)) + if (ShouldHandleSeries(notification.Definition, message.EpisodeInfo.Series)) { if (downloadMessage.OldFiles.Empty() || ((NotificationDefinition)notification.Definition).OnUpgrade) { diff --git a/src/NzbDrone.Core/Notifications/Plex/Models/PlexSection.cs b/src/NzbDrone.Core/Notifications/Plex/Models/PlexSection.cs index 71aab1988..beeb96be3 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Models/PlexSection.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Models/PlexSection.cs @@ -11,6 +11,11 @@ namespace NzbDrone.Core.Notifications.Plex.Models public class PlexSection { + public PlexSection() + { + Locations = new List(); + } + [JsonProperty("key")] public int Id { get; set; } @@ -23,6 +28,11 @@ namespace NzbDrone.Core.Notifications.Plex.Models public class PlexSectionsContainer { + public PlexSectionsContainer() + { + Sections = new List(); + } + [JsonProperty("Directory")] public List Sections { get; set; } } diff --git a/src/NzbDrone.Core/Notifications/Slack/Payloads/SlackPayload.cs b/src/NzbDrone.Core/Notifications/Slack/Payloads/SlackPayload.cs index a2c64b737..09953c115 100644 --- a/src/NzbDrone.Core/Notifications/Slack/Payloads/SlackPayload.cs +++ b/src/NzbDrone.Core/Notifications/Slack/Payloads/SlackPayload.cs @@ -12,6 +12,9 @@ namespace NzbDrone.Core.Notifications.Slack.Payloads [JsonProperty("icon_emoji")] public string IconEmoji { get; set; } + [JsonProperty("icon_url")] + public string IconUrl { get; set; } + public List Attachments { get; set; } } } diff --git a/src/NzbDrone.Core/Notifications/Slack/Slack.cs b/src/NzbDrone.Core/Notifications/Slack/Slack.cs index 8748e5b73..cbb0cb975 100644 --- a/src/NzbDrone.Core/Notifications/Slack/Slack.cs +++ b/src/NzbDrone.Core/Notifications/Slack/Slack.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; using NzbDrone.Core.Notifications.Slack.Payloads; using NzbDrone.Core.Rest; using NzbDrone.Core.Tv; @@ -14,10 +16,12 @@ namespace NzbDrone.Core.Notifications.Slack { public class Slack : NotificationBase { + private readonly ISlackProxy _proxy; private readonly Logger _logger; - public Slack(Logger logger) + public Slack(ISlackProxy proxy, Logger logger) { + _proxy = proxy; _logger = logger; } @@ -26,65 +30,51 @@ namespace NzbDrone.Core.Notifications.Slack public override void OnGrab(GrabMessage message) { - var payload = new SlackPayload - { - IconEmoji = Settings.Icon, - Username = Settings.Username, - Text = $"Grabbed: {message.Message}", - Attachments = new List - { - new Attachment - { - Fallback = message.Message, - Title = message.Series.Title, - Text = message.Message, - Color = "warning" - } - } - }; + var attachments = new List + { + new Attachment + { + Fallback = message.Message, + Title = message.Series.Title, + Text = message.Message, + Color = "warning" + } + }; + var payload = CreatePayload($"Grabbed: {message.Message}", attachments); - NotifySlack(payload); + _proxy.SendPayload(payload, Settings); } public override void OnDownload(DownloadMessage message) { - var payload = new SlackPayload - { - IconEmoji = Settings.Icon, - Username = Settings.Username, - Text = $"Imported: {message.Message}", - Attachments = new List - { - new Attachment - { - Fallback = message.Message, - Title = message.Series.Title, - Text = message.Message, - Color = "good" - } - } - }; + var attachments = new List + { + new Attachment + { + Fallback = message.Message, + Title = message.Series.Title, + Text = message.Message, + Color = "good" + } + }; + var payload = CreatePayload($"Imported: {message.Message}", attachments); - NotifySlack(payload); + _proxy.SendPayload(payload, Settings); } public override void OnRename(Series series) { - var payload = new SlackPayload - { - IconEmoji = Settings.Icon, - Username = Settings.Username, - Text = "Renamed", - Attachments = new List - { - new Attachment - { - Title = series.Title, - } - } - }; + var attachments = new List + { + new Attachment + { + Title = series.Title, + } + }; - NotifySlack(payload); + var payload = CreatePayload("Renamed", attachments); + + _proxy.SendPayload(payload, Settings); } public override ValidationResult Test() @@ -101,14 +91,9 @@ namespace NzbDrone.Core.Notifications.Slack try { var message = $"Test message from Sonarr posted at {DateTime.Now}"; - var payload = new SlackPayload - { - IconEmoji = Settings.Icon, - Username = Settings.Username, - Text = message - }; + var payload = CreatePayload(message); - NotifySlack(payload); + _proxy.SendPayload(payload, Settings); } catch (SlackExeption ex) @@ -119,24 +104,31 @@ namespace NzbDrone.Core.Notifications.Slack return null; } - private void NotifySlack(SlackPayload payload) + private SlackPayload CreatePayload(string message, List attachments = null) { - try + var icon = Settings.Icon; + + var payload = new SlackPayload { - var client = RestClientFactory.BuildClient(Settings.WebHookUrl); - var request = new RestRequest(Method.POST) + Username = Settings.Username, + Text = message, + Attachments = attachments + }; + + if (icon.IsNotNullOrWhiteSpace()) + { + // Set the correct icon based on the value + if (icon.StartsWith(":") && icon.EndsWith(":")) { - RequestFormat = DataFormat.Json, - JsonSerializer = new JsonNetSerializer() - }; - request.AddBody(payload); - client.ExecuteAndValidate(request); - } - catch (RestException ex) - { - _logger.Error(ex, "Unable to post payload {0}", payload); - throw new SlackExeption("Unable to post payload", ex); + payload.IconEmoji = icon; + } + else + { + payload.IconUrl = icon; + } } + + return payload; } } } diff --git a/src/NzbDrone.Core/Notifications/Slack/SlackProxy.cs b/src/NzbDrone.Core/Notifications/Slack/SlackProxy.cs new file mode 100644 index 000000000..d612cf012 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Slack/SlackProxy.cs @@ -0,0 +1,46 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Notifications.Slack.Payloads; +using NzbDrone.Core.Rest; + +namespace NzbDrone.Core.Notifications.Slack +{ + public interface ISlackProxy + { + void SendPayload(SlackPayload payload, SlackSettings settings); + } + + public class SlackProxy : ISlackProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public SlackProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public void SendPayload(SlackPayload payload, SlackSettings settings) + { + try + { + var request = new HttpRequestBuilder(settings.WebHookUrl) + .Accept(HttpAccept.Json) + .Build(); + + request.Method = HttpMethod.POST; + request.Headers.ContentType = "application/json"; + request.SetContent(payload.ToJson()); + + _httpClient.Execute(request); + } + catch (RestException ex) + { + _logger.Error(ex, "Unable to post payload {0}", payload); + throw new SlackExeption("Unable to post payload", ex); + } + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Slack/SlackSettings.cs b/src/NzbDrone.Core/Notifications/Slack/SlackSettings.cs index f64daddb5..bef9b5d69 100644 --- a/src/NzbDrone.Core/Notifications/Slack/SlackSettings.cs +++ b/src/NzbDrone.Core/Notifications/Slack/SlackSettings.cs @@ -24,7 +24,7 @@ namespace NzbDrone.Core.Notifications.Slack [FieldDefinition(1, Label = "Username", HelpText = "Choose the username that this integration will post as", Type = FieldType.Textbox)] public string Username { get; set; } - [FieldDefinition(2, Label = "Icon", HelpText = "Change the icon that is used for messages from this integration", Type = FieldType.Textbox, HelpLink = "http://www.emoji-cheat-sheet.com/")] + [FieldDefinition(2, Label = "Icon", HelpText = "Change the icon that is used for messages from this integration (Emoji or URL)", Type = FieldType.Textbox, HelpLink = "http://www.emoji-cheat-sheet.com/")] public string Icon { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Notifications/Webhook/Webhook.cs b/src/NzbDrone.Core/Notifications/Webhook/Webhook.cs index 4bfcb867c..49037b701 100644 --- a/src/NzbDrone.Core/Notifications/Webhook/Webhook.cs +++ b/src/NzbDrone.Core/Notifications/Webhook/Webhook.cs @@ -1,35 +1,77 @@ - -using System.Collections.Generic; +using System.Collections.Generic; +using System.Linq; using FluentValidation.Results; using NzbDrone.Core.Tv; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Notifications.Webhook { public class Webhook : NotificationBase { - private readonly IWebhookService _service; + private readonly IWebhookProxy _proxy; - public Webhook(IWebhookService service) + public Webhook(IWebhookProxy proxy) { - _service = service; + _proxy = proxy; } public override string Link => "https://github.com/Sonarr/Sonarr/wiki/Webhook"; public override void OnGrab(GrabMessage message) { - _service.OnGrab(message.Series, message.Episode, message.Quality, Settings); + var remoteEpisode = message.Episode; + var quality = message.Quality; + + var payload = new WebhookGrabPayload + { + EventType = "Grab", + Series = new WebhookSeries(message.Series), + Episodes = remoteEpisode.Episodes.ConvertAll(x => new WebhookEpisode(x) + { + // TODO: Stop passing these parameters inside an episode v3 + Quality = quality.Quality.Name, + QualityVersion = quality.Revision.Version, + ReleaseGroup = remoteEpisode.ParsedEpisodeInfo.ReleaseGroup + }), + Release = new WebhookRelease(quality, remoteEpisode) + }; + + _proxy.SendWebhook(payload, Settings); } public override void OnDownload(DownloadMessage message) { - _service.OnDownload(message.Series, message.EpisodeFile, Settings); + var episodeFile = message.EpisodeFile; + + var payload = new WebhookImportPayload + { + EventType = "Download", + Series = new WebhookSeries(message.Series), + Episodes = episodeFile.Episodes.Value.ConvertAll(x => new WebhookEpisode(x) + { + // TODO: Stop passing these parameters inside an episode v3 + Quality = episodeFile.Quality.Quality.Name, + QualityVersion = episodeFile.Quality.Revision.Version, + ReleaseGroup = episodeFile.ReleaseGroup, + SceneName = episodeFile.SceneName + }), + EpisodeFile = new WebhookEpisodeFile(episodeFile), + IsUpgrade = message.OldFiles.Any() + }; + + _proxy.SendWebhook(payload, Settings); } public override void OnRename(Series series) { - _service.OnRename(series, Settings); + var payload = new WebhookPayload + { + EventType = "Rename", + Series = new WebhookSeries(series) + }; + + _proxy.SendWebhook(payload, Settings); } public override string Name => "Webhook"; @@ -38,9 +80,44 @@ namespace NzbDrone.Core.Notifications.Webhook { var failures = new List(); - failures.AddIfNotNull(_service.Test(Settings)); + failures.AddIfNotNull(SendWebhookTest()); return new ValidationResult(failures); } + + private ValidationFailure SendWebhookTest() + { + try + { + var payload = new WebhookGrabPayload + { + EventType = "Test", + Series = new WebhookSeries() + { + Id = 1, + Title = "Test Title", + Path = "C:\\testpath", + TvdbId = 1234 + }, + Episodes = new List() { + new WebhookEpisode() + { + Id = 123, + EpisodeNumber = 1, + SeasonNumber = 1, + Title = "Test title" + } + } + }; + + _proxy.SendWebhook(payload, Settings); + } + catch (WebhookException ex) + { + return new NzbDroneValidationFailure("Url", ex.Message); + } + + return null; + } } } diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookEpisodeFile.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookEpisodeFile.cs new file mode 100644 index 000000000..ad2779538 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookEpisodeFile.cs @@ -0,0 +1,28 @@ +using NzbDrone.Core.MediaFiles; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookEpisodeFile + { + public WebhookEpisodeFile() { } + + public WebhookEpisodeFile(EpisodeFile episodeFile) + { + Id = episodeFile.Id; + RelativePath = episodeFile.RelativePath; + Path = episodeFile.Path; + Quality = episodeFile.Quality.Quality.Name; + QualityVersion = episodeFile.Quality.Revision.Version; + ReleaseGroup = episodeFile.ReleaseGroup; + SceneName = episodeFile.SceneName; + } + + public int Id { get; set; } + public string RelativePath { get; set; } + public string Path { get; set; } + public string Quality { get; set; } + public int QualityVersion { get; set; } + public string ReleaseGroup { get; set; } + public string SceneName { get; set; } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookGrabPayload.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookGrabPayload.cs new file mode 100644 index 000000000..2498f9c96 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookGrabPayload.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookGrabPayload : WebhookPayload + { + public List Episodes { get; set; } + public WebhookRelease Release { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookImportPayload.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookImportPayload.cs new file mode 100644 index 000000000..bc765b8dc --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookImportPayload.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookImportPayload : WebhookPayload + { + public List Episodes { get; set; } + public WebhookEpisodeFile EpisodeFile { get; set; } + public bool IsUpgrade { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs index 42c080e00..a3d27a575 100644 --- a/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs @@ -1,8 +1,10 @@ -namespace NzbDrone.Core.Notifications.Webhook +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Notifications.Webhook { public enum WebhookMethod { - POST = RestSharp.Method.POST, - PUT = RestSharp.Method.PUT + POST = HttpMethod.POST, + PUT = HttpMethod.PUT } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookPayload.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookPayload.cs index 41009a695..6dffbb714 100644 --- a/src/NzbDrone.Core/Notifications/Webhook/WebhookPayload.cs +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookPayload.cs @@ -1,11 +1,8 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.Notifications.Webhook +namespace NzbDrone.Core.Notifications.Webhook { public class WebhookPayload { public string EventType { get; set; } public WebhookSeries Series { get; set; } - public List Episodes { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs new file mode 100644 index 000000000..1a6260252 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs @@ -0,0 +1,41 @@ +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Rest; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public interface IWebhookProxy + { + void SendWebhook(WebhookPayload payload, WebhookSettings settings); + } + + public class WebhookProxy : IWebhookProxy + { + private readonly IHttpClient _httpClient; + + public WebhookProxy(IHttpClient httpClient) + { + _httpClient = httpClient; + } + + public void SendWebhook(WebhookPayload body, WebhookSettings settings) + { + try + { + var request = new HttpRequestBuilder(settings.Url) + .Accept(HttpAccept.Json) + .Build(); + + request.Method = (HttpMethod)settings.Method; + request.Headers.ContentType = "application/json"; + request.SetContent(body.ToJson()); + + _httpClient.Execute(request); + } + catch (RestException ex) + { + throw new WebhookException("Unable to post to webhook: {0}", ex, ex.Message); + } + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookRelease.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookRelease.cs new file mode 100644 index 000000000..dfab9c889 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookRelease.cs @@ -0,0 +1,27 @@ +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookRelease + { + public WebhookRelease() { } + + public WebhookRelease(QualityModel quality, RemoteEpisode remoteEpisode) + { + Quality = quality.Quality.Name; + QualityVersion = quality.Revision.Version; + ReleaseGroup = remoteEpisode.ParsedEpisodeInfo.ReleaseGroup; + ReleaseTitle = remoteEpisode.Release.Title; + Indexer = remoteEpisode.Release.Indexer; + Size = remoteEpisode.Release.Size; + } + + public string Quality { get; set; } + public int QualityVersion { get; set; } + public string ReleaseGroup { get; set; } + public string ReleaseTitle { get; set; } + public string Indexer { get; set; } + public long Size { get; set; } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookService.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookService.cs deleted file mode 100644 index b04efa168..000000000 --- a/src/NzbDrone.Core/Notifications/Webhook/WebhookService.cs +++ /dev/null @@ -1,118 +0,0 @@ -using FluentValidation.Results; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Validation; -using NzbDrone.Core.Rest; -using RestSharp; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Parser.Model; -using System.Collections.Generic; - -namespace NzbDrone.Core.Notifications.Webhook -{ - public interface IWebhookService - { - void OnDownload(Series series, EpisodeFile episodeFile, WebhookSettings settings); - void OnRename(Series series, WebhookSettings settings); - void OnGrab(Series series, RemoteEpisode episode, QualityModel quality, WebhookSettings settings); - ValidationFailure Test(WebhookSettings settings); - } - - public class WebhookService : IWebhookService - { - public void OnDownload(Series series, EpisodeFile episodeFile, WebhookSettings settings) - { - var payload = new WebhookPayload - { - EventType = "Download", - Series = new WebhookSeries(series), - Episodes = episodeFile.Episodes.Value.ConvertAll(x => new WebhookEpisode(x) { - Quality = episodeFile.Quality.Quality.Name, - QualityVersion = episodeFile.Quality.Revision.Version, - ReleaseGroup = episodeFile.ReleaseGroup, - SceneName = episodeFile.SceneName - }) - }; - - NotifyWebhook(payload, settings); - } - - public void OnRename(Series series, WebhookSettings settings) - { - var payload = new WebhookPayload - { - EventType = "Rename", - Series = new WebhookSeries(series) - }; - - NotifyWebhook(payload, settings); - } - - public void OnGrab(Series series, RemoteEpisode episode, QualityModel quality, WebhookSettings settings) - { - var payload = new WebhookPayload - { - EventType = "Grab", - Series = new WebhookSeries(series), - Episodes = episode.Episodes.ConvertAll(x => new WebhookEpisode(x) - { - Quality = quality.Quality.Name, - QualityVersion = quality.Revision.Version, - ReleaseGroup = episode.ParsedEpisodeInfo.ReleaseGroup - }) - }; - NotifyWebhook(payload, settings); - } - - public void NotifyWebhook(WebhookPayload body, WebhookSettings settings) - { - try { - var client = RestClientFactory.BuildClient(settings.Url); - var request = new RestRequest((Method) settings.Method); - request.RequestFormat = DataFormat.Json; - request.AddBody(body); - client.ExecuteAndValidate(request); - } - catch (RestException ex) - { - throw new WebhookException("Unable to post to webhook: {0}", ex, ex.Message); - } - } - - public ValidationFailure Test(WebhookSettings settings) - { - try - { - NotifyWebhook( - new WebhookPayload - { - EventType = "Test", - Series = new WebhookSeries() - { - Id = 1, - Title = "Test Title", - Path = "C:\\testpath", - TvdbId = 1234 - }, - Episodes = new List() { - new WebhookEpisode() - { - Id = 123, - EpisodeNumber = 1, - SeasonNumber = 1, - Title = "Test title" - } - } - }, - settings - ); - } - catch (WebhookException ex) - { - return new NzbDroneValidationFailure("Url", ex.Message); - } - - return null; - } - } -} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 9e238632b..afdbf7791 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -124,6 +124,7 @@ + @@ -140,6 +141,7 @@ + @@ -248,6 +250,11 @@ + + + + + @@ -282,11 +289,14 @@ + Code + + @@ -315,6 +325,7 @@ + @@ -330,6 +341,7 @@ + @@ -337,9 +349,10 @@ - + + @@ -356,6 +369,7 @@ + @@ -404,9 +418,7 @@ - - Code - + @@ -488,7 +500,11 @@ + + + + @@ -502,7 +518,7 @@ - + @@ -551,6 +567,10 @@ + + + + @@ -565,6 +585,7 @@ + @@ -575,11 +596,13 @@ + + @@ -621,7 +644,9 @@ + + @@ -637,6 +662,7 @@ + @@ -659,6 +685,7 @@ + @@ -722,6 +749,7 @@ + @@ -750,6 +778,8 @@ + + @@ -757,8 +787,8 @@ - + @@ -767,12 +797,14 @@ + Code + @@ -866,6 +898,7 @@ + @@ -877,12 +910,16 @@ + + + + - + @@ -1058,6 +1095,7 @@ + @@ -1069,6 +1107,9 @@ + + + diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 31cbd53ef..ff9ae607b 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -9,6 +9,7 @@ using NzbDrone.Common.Cache; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.Qualities; using NzbDrone.Core.Tv; @@ -52,7 +53,7 @@ namespace NzbDrone.Core.Organizer public static readonly Regex AirDateRegex = new Regex(@"\{Air(\s|\W|_)Date\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); - public static readonly Regex SeriesTitleRegex = new Regex(@"(?\{(?:Series)(?[- ._])(Clean)?Title\})", + public static readonly Regex SeriesTitleRegex = new Regex(@"(?\{(?:Series)(?[- ._])(Clean)?Title(The)?\})", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex FileNameCleanupRegex = new Regex(@"([- ._])(\1)+", RegexOptions.Compiled); @@ -66,6 +67,8 @@ namespace NzbDrone.Core.Organizer private static readonly char[] EpisodeTitleTrimCharacters = new[] { ' ', '.', '?' }; + private static readonly Regex TitlePrefixRegex = new Regex(@"^(The|An|A) (.*?)((?: *\([^)]+\))*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public FileNameBuilder(INamingConfigService namingConfigService, IQualityDefinitionService qualityDefinitionService, ICacheManager cacheManager, @@ -128,7 +131,7 @@ namespace NzbDrone.Core.Organizer AddEpisodeFileTokens(tokenHandlers, episodeFile); AddQualityTokens(tokenHandlers, series, episodeFile); AddMediaInfoTokens(tokenHandlers, episodeFile); - + var fileName = ReplaceTokens(pattern, tokenHandlers, namingConfig).Trim(); fileName = FileNameCleanupRegex.Replace(fileName, match => match.Captures[0].Value[0].ToString()); fileName = TrimSeparatorsRegex.Replace(fileName, string.Empty); @@ -252,6 +255,11 @@ namespace NzbDrone.Core.Organizer return title; } + public static string TitleThe(string title) + { + return TitlePrefixRegex.Replace(title, "$2, $1$3"); + } + public static string CleanFileName(string name, bool replace = true) { string result = name; @@ -276,6 +284,7 @@ namespace NzbDrone.Core.Organizer { tokenHandlers["{Series Title}"] = m => series.Title; tokenHandlers["{Series CleanTitle}"] = m => CleanTitle(series.Title); + tokenHandlers["{Series TitleThe}"] = m => TitleThe(series.Title); } private string AddSeasonEpisodeNumberingTokens(string pattern, Dictionary> tokenHandlers, List episodes, NamingConfig namingConfig) @@ -445,75 +454,16 @@ namespace NzbDrone.Core.Organizer { if (episodeFile.MediaInfo == null) return; - string videoCodec; - switch (episodeFile.MediaInfo.VideoCodec) - { - case "AVC": - if (episodeFile.SceneName.IsNotNullOrWhiteSpace() && Path.GetFileNameWithoutExtension(episodeFile.SceneName).Contains("h264")) - { - videoCodec = "h264"; - } - else - { - videoCodec = "x264"; - } - break; + var sceneName = episodeFile.GetSceneOrFileName(); - case "V_MPEGH/ISO/HEVC": - if (episodeFile.SceneName.IsNotNullOrWhiteSpace() && Path.GetFileNameWithoutExtension(episodeFile.SceneName).Contains("h265")) - { - videoCodec = "h265"; - } - else - { - videoCodec = "x265"; - } - break; - - case "MPEG-2 Video": - videoCodec = "MPEG2"; - break; - - default: - videoCodec = episodeFile.MediaInfo.VideoCodec; - break; - } - - string audioCodec; - switch (episodeFile.MediaInfo.AudioFormat) - { - case "AC-3": - audioCodec = "AC3"; - break; - - case "E-AC-3": - audioCodec = "EAC3"; - break; - - case "MPEG Audio": - if (episodeFile.MediaInfo.AudioProfile == "Layer 3") - { - audioCodec = "MP3"; - } - else - { - audioCodec = episodeFile.MediaInfo.AudioFormat; - } - break; - - case "DTS": - audioCodec = episodeFile.MediaInfo.AudioFormat; - break; - - default: - audioCodec = episodeFile.MediaInfo.AudioFormat; - break; - } + var videoCodec = MediaInfoFormatter.FormatVideoCodec(episodeFile.MediaInfo, sceneName); + var audioCodec = MediaInfoFormatter.FormatAudioCodec(episodeFile.MediaInfo, sceneName); + var audioChannels = MediaInfoFormatter.FormatAudioChannels(episodeFile.MediaInfo); var mediaInfoAudioLanguages = GetLanguagesToken(episodeFile.MediaInfo.AudioLanguages); if (!mediaInfoAudioLanguages.IsNullOrWhiteSpace()) { - mediaInfoAudioLanguages = string.Format("[{0}]", mediaInfoAudioLanguages); + mediaInfoAudioLanguages = $"[{mediaInfoAudioLanguages}]"; } if (mediaInfoAudioLanguages == "[EN]") @@ -524,12 +474,12 @@ namespace NzbDrone.Core.Organizer var mediaInfoSubtitleLanguages = GetLanguagesToken(episodeFile.MediaInfo.Subtitles); if (!mediaInfoSubtitleLanguages.IsNullOrWhiteSpace()) { - mediaInfoSubtitleLanguages = string.Format("[{0}]", mediaInfoSubtitleLanguages); + mediaInfoSubtitleLanguages = $"[{mediaInfoSubtitleLanguages}]"; } var videoBitDepth = episodeFile.MediaInfo.VideoBitDepth > 0 ? episodeFile.MediaInfo.VideoBitDepth.ToString() : string.Empty; - var audioChannels = episodeFile.MediaInfo.FormattedAudioChannels > 0 ? - episodeFile.MediaInfo.FormattedAudioChannels.ToString("F1", CultureInfo.InvariantCulture) : + var audioChannelsFormatted = audioChannels > 0 ? + audioChannels.ToString("F1", CultureInfo.InvariantCulture) : string.Empty; tokenHandlers["{MediaInfo Video}"] = m => videoCodec; @@ -538,11 +488,11 @@ namespace NzbDrone.Core.Organizer tokenHandlers["{MediaInfo Audio}"] = m => audioCodec; tokenHandlers["{MediaInfo AudioCodec}"] = m => audioCodec; - tokenHandlers["{MediaInfo AudioChannels}"] = m => audioChannels; + tokenHandlers["{MediaInfo AudioChannels}"] = m => audioChannelsFormatted; - tokenHandlers["{MediaInfo Simple}"] = m => string.Format("{0} {1}", videoCodec, audioCodec); + tokenHandlers["{MediaInfo Simple}"] = m => $"{videoCodec} {audioCodec}"; - tokenHandlers["{MediaInfo Full}"] = m => string.Format("{0} {1}{2} {3}", videoCodec, audioCodec, mediaInfoAudioLanguages, mediaInfoSubtitleLanguages); + tokenHandlers["{MediaInfo Full}"] = m => $"{videoCodec} {audioCodec}{mediaInfoAudioLanguages} {mediaInfoSubtitleLanguages}"; } private string GetLanguagesToken(string mediaInfoLanguages) diff --git a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs index 966061fb3..de1046485 100644 --- a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs +++ b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs @@ -41,19 +41,19 @@ namespace NzbDrone.Core.Organizer _standardSeries = new Series { SeriesType = SeriesTypes.Standard, - Title = "Series Title (2010)" + Title = "The Series Title (2010)" }; _dailySeries = new Series { SeriesType = SeriesTypes.Daily, - Title = "Series Title (2010)" + Title = "The Series Title (2010)" }; _animeSeries = new Series { SeriesType = SeriesTypes.Anime, - Title = "Series Title (2010)" + Title = "The Series Title (2010)" }; _episode1 = new Episode diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs index 256269c36..d38001715 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -6,6 +6,7 @@ namespace NzbDrone.Core.Parser.Model { public class ParsedEpisodeInfo { + public string ReleaseTitle { get; set; } public string SeriesTitle { get; set; } public SeriesTitleInfo SeriesTitleInfo { get; set; } public QualityModel Quality { get; set; } @@ -89,4 +90,4 @@ namespace NzbDrone.Core.Parser.Model return string.Format("{0} - {1} {2}", SeriesTitle, episodeString, Quality); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 4855926a9..36d54664b 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -122,8 +122,8 @@ namespace NzbDrone.Core.Parser new Regex(@"^(?.+?)[-_. ]S(?<season>(?<!\d+)(?:\d{1,2}|\d{4})(?!\d+))(?:[-_. ]?[ex]?(?<episode>(?<!\d+)\d{1,2}(?!\d+)))+", RegexOptions.IgnoreCase | RegexOptions.Compiled), - //Single episode season or episode S1E1 or S1-E1 - new Regex(@"(?:.*(?:\""|^))(?<title>.*?)(?:\W?|_)S(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:\W|_)?E(?<episode>(?<!\d+)\d{1,2}(?!\d+))", + //Single episode season or episode S1E1 or S1-E1 or S1.Ep1 + new Regex(@"(?:.*(?:\""|^))(?<title>.*?)(?:\W?|_)S(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:\W|_)?Ep?(?<episode>(?<!\d+)\d{1,2}(?!\d+))", RegexOptions.IgnoreCase | RegexOptions.Compiled), //3 digit season S010E05 @@ -209,7 +209,7 @@ namespace NzbDrone.Core.Parser { // Generic match for md5 and mixed-case hashes. new Regex(@"^[0-9a-zA-Z]{32}", RegexOptions.Compiled), - + // Generic match for shorter lower-case hashes. new Regex(@"^[a-z0-9]{24}$", RegexOptions.Compiled), @@ -252,7 +252,7 @@ namespace NzbDrone.Core.Parser private static readonly Regex SixDigitAirDateRegex = new Regex(@"(?<=[_.-])(?<airdate>(?<!\d)(?<airyear>[1-9]\d{1})(?<airmonth>[0-1][0-9])(?<airday>[0-3][0-9]))(?=[_.-])", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex CleanReleaseGroupRegex = new Regex(@"^(.*?[-._ ](S\d+E\d+)[-._ ])|(-(RP|1|NZBGeek|Obfuscated|sample))+$", + private static readonly Regex CleanReleaseGroupRegex = new Regex(@"^(.*?[-._ ](S\d+E\d+)[-._ ])|(-(RP|1|NZBGeek|Obfuscated|Scrambled|sample))+$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex CleanTorrentSuffixRegex = new Regex(@"\[(?:ettv|rartv|rarbg|cttv)\]$", @@ -316,9 +316,9 @@ namespace NzbDrone.Core.Parser Logger.Debug("Reversed name detected. Converted to '{0}'", title); } - var simpleTitle = SimpleTitleRegex.Replace(title, string.Empty); + var releaseTitle = RemoveFileExtension(title); - simpleTitle = RemoveFileExtension(simpleTitle); + var simpleTitle = SimpleTitleRegex.Replace(releaseTitle, string.Empty); // TODO: Quick fix stripping [url] - prefixes. simpleTitle = WebsitePrefixRegex.Replace(simpleTitle, string.Empty); @@ -355,7 +355,7 @@ namespace NzbDrone.Core.Parser Logger.Trace(regex); try { - var result = ParseMatchCollection(match); + var result = ParseMatchCollection(match, title); if (result != null) { @@ -365,13 +365,13 @@ namespace NzbDrone.Core.Parser result.Special = true; } - result.Language = LanguageParser.ParseLanguage(title); + result.Language = LanguageParser.ParseLanguage(releaseTitle); Logger.Debug("Language parsed: {0}", result.Language); result.Quality = QualityParser.ParseQuality(title); Logger.Debug("Quality parsed: {0}", result.Quality); - result.ReleaseGroup = ParseReleaseGroup(title); + result.ReleaseGroup = ParseReleaseGroup(releaseTitle); var subGroup = GetSubGroup(match); if (!subGroup.IsNullOrWhiteSpace()) @@ -522,7 +522,7 @@ namespace NzbDrone.Core.Parser return seriesTitleInfo; } - private static ParsedEpisodeInfo ParseMatchCollection(MatchCollection matchCollection) + private static ParsedEpisodeInfo ParseMatchCollection(MatchCollection matchCollection, string releaseTitle) { var seriesName = matchCollection[0].Groups["title"].Value.Replace('.', ' ').Replace('_', ' '); seriesName = RequestInfoRegex.Replace(seriesName, "").Trim(' '); @@ -551,6 +551,7 @@ namespace NzbDrone.Core.Parser result = new ParsedEpisodeInfo { + ReleaseTitle = releaseTitle, SeasonNumber = seasons.First(), EpisodeNumbers = new int[0], AbsoluteEpisodeNumbers = new int[0] @@ -644,6 +645,7 @@ namespace NzbDrone.Core.Parser result = new ParsedEpisodeInfo { + ReleaseTitle = releaseTitle, AirDate = airDate.ToString(Episode.AIR_DATE_FORMAT), }; } diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 6d0ee7f6c..eae9bb3d6 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Core.Parser RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int tvdbId, int tvRageId, SearchCriteriaBase searchCriteria = null); RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int seriesId, IEnumerable<int> episodeIds); List<Episode> GetEpisodes(ParsedEpisodeInfo parsedEpisodeInfo, Series series, bool sceneSource, SearchCriteriaBase searchCriteria = null); - ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, int tvdbId, int tvRageId, SearchCriteriaBase searchCriteria = null); + ParsedEpisodeInfo ParseSpecialEpisodeTitle(string releaseTitle, int tvdbId, int tvRageId, SearchCriteriaBase searchCriteria = null); } public class ParsingService : IParsingService @@ -103,6 +103,13 @@ namespace NzbDrone.Core.Parser return _seriesService.FindByTitle(title); } + var tvdbId = _sceneMappingService.FindTvdbId(parsedEpisodeInfo.SeriesTitle, parsedEpisodeInfo.ReleaseTitle); + + if (tvdbId.HasValue) + { + return _seriesService.FindByTvdbId(tvdbId.Value); + } + var series = _seriesService.FindByTitle(parsedEpisodeInfo.SeriesTitle); if (series == null) @@ -177,29 +184,26 @@ namespace NzbDrone.Core.Parser return GetStandardEpisodes(series, parsedEpisodeInfo, sceneSource, searchCriteria); } - public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, int tvdbId, int tvRageId, SearchCriteriaBase searchCriteria = null) + public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string releaseTitle, int tvdbId, int tvRageId, SearchCriteriaBase searchCriteria = null) { if (searchCriteria != null) { - if (tvdbId == 0) - tvdbId = _sceneMappingService.FindTvdbId(title) ?? 0; - if (tvdbId != 0 && tvdbId == searchCriteria.Series.TvdbId) { - return ParseSpecialEpisodeTitle(title, searchCriteria.Series); + return ParseSpecialEpisodeTitle(releaseTitle, searchCriteria.Series); } if (tvRageId != 0 && tvRageId == searchCriteria.Series.TvRageId) { - return ParseSpecialEpisodeTitle(title, searchCriteria.Series); + return ParseSpecialEpisodeTitle(releaseTitle, searchCriteria.Series); } } - var series = GetSeries(title); + var series = GetSeries(releaseTitle); if (series == null) { - series = _seriesService.FindByTitleInexact(title); + series = _seriesService.FindByTitleInexact(releaseTitle); } if (series == null && tvdbId > 0) @@ -214,34 +218,39 @@ namespace NzbDrone.Core.Parser if (series == null) { - _logger.Debug("No matching series {0}", title); + _logger.Debug("No matching series {0}", releaseTitle); return null; } - return ParseSpecialEpisodeTitle(title, series); + return ParseSpecialEpisodeTitle(releaseTitle, series); } - private ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series) + private ParsedEpisodeInfo ParseSpecialEpisodeTitle(string releaseTitle, Series series) { // find special episode in series season 0 - var episode = _episodeService.FindEpisodeByTitle(series.Id, 0, title); + var episode = _episodeService.FindEpisodeByTitle(series.Id, 0, releaseTitle); if (episode != null) { // create parsed info from tv episode - var info = new ParsedEpisodeInfo(); - info.SeriesTitle = series.Title; - info.SeriesTitleInfo = new SeriesTitleInfo(); - info.SeriesTitleInfo.Title = info.SeriesTitle; - info.SeasonNumber = episode.SeasonNumber; - info.EpisodeNumbers = new int[1] { episode.EpisodeNumber }; - info.FullSeason = false; - info.Quality = QualityParser.ParseQuality(title); - info.ReleaseGroup = Parser.ParseReleaseGroup(title); - info.Language = LanguageParser.ParseLanguage(title); - info.Special = true; + var info = new ParsedEpisodeInfo + { + ReleaseTitle = releaseTitle, + SeriesTitle = series.Title, + SeriesTitleInfo = new SeriesTitleInfo + { + Title = series.Title + }, + SeasonNumber = episode.SeasonNumber, + EpisodeNumbers = new int[1] { episode.EpisodeNumber }, + FullSeason = false, + Quality = QualityParser.ParseQuality(releaseTitle), + ReleaseGroup = Parser.ParseReleaseGroup(releaseTitle), + Language = LanguageParser.ParseLanguage(releaseTitle), + Special = true + }; - _logger.Debug("Found special episode {0} for title '{1}'", info, title); + _logger.Debug("Found special episode {0} for title '{1}'", info, releaseTitle); return info; } @@ -252,7 +261,7 @@ namespace NzbDrone.Core.Parser { Series series = null; - var sceneMappingTvdbId = _sceneMappingService.FindTvdbId(parsedEpisodeInfo.SeriesTitle); + var sceneMappingTvdbId = _sceneMappingService.FindTvdbId(parsedEpisodeInfo.SeriesTitle, parsedEpisodeInfo.ReleaseTitle); if (sceneMappingTvdbId.HasValue) { if (searchCriteria != null && searchCriteria.Series.TvdbId == sceneMappingTvdbId.Value) @@ -341,7 +350,7 @@ namespace NzbDrone.Core.Parser { var result = new List<Episode>(); - var sceneSeasonNumber = _sceneMappingService.GetSceneSeasonNumber(parsedEpisodeInfo.SeriesTitle); + var sceneSeasonNumber = _sceneMappingService.GetSceneSeasonNumber(parsedEpisodeInfo.SeriesTitle, parsedEpisodeInfo.ReleaseTitle); foreach (var absoluteEpisodeNumber in parsedEpisodeInfo.AbsoluteEpisodeNumbers) { @@ -405,7 +414,7 @@ namespace NzbDrone.Core.Parser if (sceneSource) { - var sceneMapping = _sceneMappingService.FindSceneMapping(parsedEpisodeInfo.SeriesTitle); + var sceneMapping = _sceneMappingService.FindSceneMapping(parsedEpisodeInfo.SeriesTitle, parsedEpisodeInfo.ReleaseTitle); if (sceneMapping != null && sceneMapping.SeasonNumber.HasValue && sceneMapping.SeasonNumber.Value >= 0 && sceneMapping.SceneSeasonNumber == seasonNumber) @@ -475,4 +484,4 @@ namespace NzbDrone.Core.Parser return result; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/ThingiProvider/Events/ProviderStatusChangedEvent.cs b/src/NzbDrone.Core/ThingiProvider/Events/ProviderStatusChangedEvent.cs new file mode 100644 index 000000000..8def1f0c7 --- /dev/null +++ b/src/NzbDrone.Core/ThingiProvider/Events/ProviderStatusChangedEvent.cs @@ -0,0 +1,18 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.ThingiProvider.Events +{ + public class ProviderStatusChangedEvent<TProvider> : IEvent + { + public int ProviderId { get; private set; } + + public ProviderStatusBase Status { get; private set; } + + public ProviderStatusChangedEvent(int id, ProviderStatusBase status) + { + ProviderId = id; + Status = status; + } + } +} diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs index 0c64aa994..57c6d4e88 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -76,7 +76,7 @@ namespace NzbDrone.Core.ThingiProvider return definitions; } - public ValidationResult Test(TProviderDefinition definition) + public virtual ValidationResult Test(TProviderDefinition definition) { return GetInstance(definition).Test(); } @@ -168,4 +168,4 @@ namespace NzbDrone.Core.ThingiProvider } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/ThingiProvider/Status/ProviderStatusBase.cs b/src/NzbDrone.Core/ThingiProvider/Status/ProviderStatusBase.cs new file mode 100644 index 000000000..cbf101f39 --- /dev/null +++ b/src/NzbDrone.Core/ThingiProvider/Status/ProviderStatusBase.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.ThingiProvider.Status +{ + public abstract class ProviderStatusBase : ModelBase + { + public int ProviderId { get; set; } + + public DateTime? InitialFailure { get; set; } + public DateTime? MostRecentFailure { get; set; } + public int EscalationLevel { get; set; } + public DateTime? DisabledTill { get; set; } + + public virtual bool IsDisabled() + { + return DisabledTill.HasValue && DisabledTill.Value > DateTime.UtcNow; + } + } +} diff --git a/src/NzbDrone.Core/ThingiProvider/Status/ProviderStatusRepository.cs b/src/NzbDrone.Core/ThingiProvider/Status/ProviderStatusRepository.cs new file mode 100644 index 000000000..2c1b184f1 --- /dev/null +++ b/src/NzbDrone.Core/ThingiProvider/Status/ProviderStatusRepository.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.ThingiProvider.Status +{ + public interface IProviderStatusRepository<TModel> : IBasicRepository<TModel> + where TModel : ProviderStatusBase, new() + { + TModel FindByProviderId(int providerId); + } + + public class ProviderStatusRepository<TModel> : BasicRepository<TModel>, IProviderStatusRepository<TModel> + where TModel : ProviderStatusBase, new() + + { + public ProviderStatusRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public TModel FindByProviderId(int providerId) + { + return Query.Where(c => c.ProviderId == providerId).SingleOrDefault(); + } + } +} diff --git a/src/NzbDrone.Core/ThingiProvider/Status/ProviderStatusServiceBase.cs b/src/NzbDrone.Core/ThingiProvider/Status/ProviderStatusServiceBase.cs new file mode 100644 index 000000000..7c5710e7b --- /dev/null +++ b/src/NzbDrone.Core/ThingiProvider/Status/ProviderStatusServiceBase.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.ThingiProvider.Status +{ + public interface IProviderStatusServiceBase<TModel> + where TModel : ProviderStatusBase, new() + { + List<TModel> GetBlockedProviders(); + void RecordSuccess(int providerId); + void RecordFailure(int providerId, TimeSpan minimumBackOff = default(TimeSpan)); + void RecordConnectionFailure(int providerId); + } + + public abstract class ProviderStatusServiceBase<TProvider, TModel> : IProviderStatusServiceBase<TModel>, IHandleAsync<ProviderDeletedEvent<TProvider>> + where TProvider : IProvider + where TModel : ProviderStatusBase, new() + { + private static readonly int[] EscalationBackOffPeriods = { + 0, + 5 * 60, + 15 * 60, + 30 * 60, + 60 * 60, + 3 * 60 * 60, + 6 * 60 * 60, + 12 * 60 * 60, + 24 * 60 * 60 + }; + + protected readonly object _syncRoot = new object(); + + protected readonly IProviderStatusRepository<TModel> _providerStatusRepository; + protected readonly IEventAggregator _eventAggregator; + protected readonly Logger _logger; + + protected int MaximumEscalationLevel { get; set; } = EscalationBackOffPeriods.Length - 1; + protected TimeSpan MinimumTimeSinceInitialFailure { get; set; } = TimeSpan.Zero; + + public ProviderStatusServiceBase(IProviderStatusRepository<TModel> providerStatusRepository, IEventAggregator eventAggregator, Logger logger) + { + _providerStatusRepository = providerStatusRepository; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public virtual List<TModel> GetBlockedProviders() + { + return _providerStatusRepository.All().Where(v => v.IsDisabled()).ToList(); + } + + protected virtual TModel GetProviderStatus(int providerId) + { + return _providerStatusRepository.FindByProviderId(providerId) ?? new TModel { ProviderId = providerId }; + } + + protected virtual TimeSpan CalculateBackOffPeriod(TModel status) + { + var level = Math.Min(MaximumEscalationLevel, status.EscalationLevel); + + return TimeSpan.FromSeconds(EscalationBackOffPeriods[level]); + } + + public virtual void RecordSuccess(int providerId) + { + lock (_syncRoot) + { + var status = GetProviderStatus(providerId); + + if (status.EscalationLevel == 0) + { + return; + } + + status.EscalationLevel--; + status.DisabledTill = null; + + _providerStatusRepository.Upsert(status); + + _eventAggregator.PublishEvent(new ProviderStatusChangedEvent<TProvider>(providerId, status)); + } + } + + protected virtual void RecordFailure(int providerId, TimeSpan minimumBackOff, bool escalate) + { + lock (_syncRoot) + { + var status = GetProviderStatus(providerId); + + var now = DateTime.UtcNow; + status.MostRecentFailure = now; + + if (status.EscalationLevel == 0) + { + status.InitialFailure = now; + status.EscalationLevel = 1; + escalate = false; + } + + var inGracePeriod = (status.InitialFailure.Value + MinimumTimeSinceInitialFailure) > now; + + if (escalate && !inGracePeriod) + { + status.EscalationLevel = Math.Min(MaximumEscalationLevel, status.EscalationLevel + 1); + } + + if (minimumBackOff != TimeSpan.Zero) + { + while (status.EscalationLevel < MaximumEscalationLevel && CalculateBackOffPeriod(status) < minimumBackOff) + { + status.EscalationLevel++; + } + } + + if (!inGracePeriod || minimumBackOff != TimeSpan.Zero) + { + status.DisabledTill = now + CalculateBackOffPeriod(status); + } + + _providerStatusRepository.Upsert(status); + + _eventAggregator.PublishEvent(new ProviderStatusChangedEvent<TProvider>(providerId, status)); + } + } + + public virtual void RecordFailure(int providerId, TimeSpan minimumBackOff = default(TimeSpan)) + { + RecordFailure(providerId, minimumBackOff, true); + } + + public virtual void RecordConnectionFailure(int providerId) + { + RecordFailure(providerId, default(TimeSpan), false); + } + + public virtual void HandleAsync(ProviderDeletedEvent<TProvider> message) + { + var providerStatus = _providerStatusRepository.FindByProviderId(message.ProviderId); + + if (providerStatus != null) + { + _providerStatusRepository.Delete(providerStatus); + } + } + } +} diff --git a/src/NzbDrone.Core/Tv/EpisodeMonitoredService.cs b/src/NzbDrone.Core/Tv/EpisodeMonitoredService.cs index b15c130be..f186ade10 100644 --- a/src/NzbDrone.Core/Tv/EpisodeMonitoredService.cs +++ b/src/NzbDrone.Core/Tv/EpisodeMonitoredService.cs @@ -34,10 +34,9 @@ namespace NzbDrone.Core.Tv if (monitoringOptions.IgnoreEpisodesWithFiles) { - _logger.Debug("Ignoring Episodes with Files"); + _logger.Debug("Unmonitoring Episodes with Files"); ToggleEpisodesMonitoredState(episodes.Where(e => e.HasFile), false); } - else { _logger.Debug("Monitoring Episodes with Files"); @@ -46,10 +45,9 @@ namespace NzbDrone.Core.Tv if (monitoringOptions.IgnoreEpisodesWithoutFiles) { - _logger.Debug("Ignoring Episodes without Files"); + _logger.Debug("Unmonitoring Episodes without Files"); ToggleEpisodesMonitoredState(episodes.Where(e => !e.HasFile && e.AirDateUtc.HasValue && e.AirDateUtc.Value.Before(DateTime.UtcNow)), false); } - else { _logger.Debug("Monitoring Episodes without Files"); @@ -62,31 +60,21 @@ namespace NzbDrone.Core.Tv { var season = s; - if (season.Monitored) + // If the season is unmonitored we should unmonitor all episodes in that season + + if (!season.Monitored) { - if (!monitoringOptions.IgnoreEpisodesWithFiles && !monitoringOptions.IgnoreEpisodesWithoutFiles) - { - ToggleEpisodesMonitoredState(episodes.Where(e => e.SeasonNumber == season.SeasonNumber), true); - } + _logger.Debug("Unmonitoring all episodes in season {0}", season.SeasonNumber); + ToggleEpisodesMonitoredState(episodes.Where(e => e.SeasonNumber == season.SeasonNumber), false); } - else - { - if (!monitoringOptions.IgnoreEpisodesWithFiles && !monitoringOptions.IgnoreEpisodesWithoutFiles) - { - ToggleEpisodesMonitoredState(episodes.Where(e => e.SeasonNumber == season.SeasonNumber), false); - } - - else if (season.SeasonNumber == 0) - { - ToggleEpisodesMonitoredState(episodes.Where(e => e.SeasonNumber == season.SeasonNumber), false); - } - } + // If the season is not the latest season and all it's episodes are unmonitored the season will be unmonitored if (season.SeasonNumber < lastSeason) { if (episodes.Where(e => e.SeasonNumber == season.SeasonNumber).All(e => !e.Monitored)) { + _logger.Debug("Unmonitoring season {0} because all episodes are not monitored", season.SeasonNumber); season.Monitored = false; } } diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index f4db2b0a4..171517ab3 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -35,21 +35,18 @@ namespace NzbDrone.Core.Tv { private readonly ISeriesRepository _seriesRepository; private readonly IEventAggregator _eventAggregator; - private readonly ISceneMappingService _sceneMappingService; private readonly IEpisodeService _episodeService; private readonly IBuildFileNames _fileNameBuilder; private readonly Logger _logger; public SeriesService(ISeriesRepository seriesRepository, IEventAggregator eventAggregator, - ISceneMappingService sceneMappingService, IEpisodeService episodeService, IBuildFileNames fileNameBuilder, Logger logger) { _seriesRepository = seriesRepository; _eventAggregator = eventAggregator; - _sceneMappingService = sceneMappingService; _episodeService = episodeService; _fileNameBuilder = fileNameBuilder; _logger = logger; @@ -85,13 +82,6 @@ namespace NzbDrone.Core.Tv public Series FindByTitle(string title) { - var tvdbId = _sceneMappingService.FindTvdbId(title); - - if (tvdbId.HasValue) - { - return _seriesRepository.FindByTvdbId(tvdbId.Value); - } - return _seriesRepository.FindByTitle(title.CleanSeriesTitle()); } @@ -107,11 +97,11 @@ namespace NzbDrone.Core.Tv } if (list.Count == 1) { - // return the first series if there is only one + // return the first series if there is only one return list.Single(); } // build ordered list of series by position in the search string - var query = + var query = list.Select(series => new { position = cleanTitle.IndexOf(series.CleanTitle), @@ -192,7 +182,7 @@ namespace NzbDrone.Core.Tv _logger.Trace("Not changing path for: {0}", s.Title); } } - + _seriesRepository.UpdateMany(series); _logger.Debug("{0} series updated", series.Count); diff --git a/src/NzbDrone.Core/Tv/SeriesTitleSlugValidator.cs b/src/NzbDrone.Core/Tv/SeriesTitleSlugValidator.cs index 97ff29095..fe329a698 100644 --- a/src/NzbDrone.Core/Tv/SeriesTitleSlugValidator.cs +++ b/src/NzbDrone.Core/Tv/SeriesTitleSlugValidator.cs @@ -1,4 +1,6 @@ -using FluentValidation.Validators; +using System.Linq; +using FluentValidation.Validators; +using NzbDrone.Common.Extensions; namespace NzbDrone.Core.Tv { @@ -7,7 +9,7 @@ namespace NzbDrone.Core.Tv private readonly ISeriesService _seriesService; public SeriesTitleSlugValidator(ISeriesService seriesService) - : base("Title slug is in use by another series with a similar name") + : base("Title slug '{slug}' is in use by series '{seriesTitle}'") { _seriesService = seriesService; } @@ -16,10 +18,25 @@ namespace NzbDrone.Core.Tv { if (context.PropertyValue == null) return true; + dynamic instance = context.ParentContext.InstanceToValidate; var instanceId = (int)instance.Id; + var slug = context.PropertyValue.ToString(); - return !_seriesService.GetAllSeries().Exists(s => s.TitleSlug.Equals(context.PropertyValue.ToString()) && s.Id != instanceId); + var conflictingSeries = _seriesService.GetAllSeries() + .FirstOrDefault(s => s.TitleSlug.IsNotNullOrWhiteSpace() && + s.TitleSlug.Equals(context.PropertyValue.ToString()) && + s.Id != instanceId); + + if (conflictingSeries == null) + { + return true; + } + + context.MessageFormatter.AppendArgument("slug", slug); + context.MessageFormatter.AppendArgument("seriesTitle", conflictingSeries.Title); + + return false; } } } diff --git a/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs b/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs index 5911a9a13..a6603d34c 100644 --- a/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs +++ b/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs @@ -6,6 +6,6 @@ namespace NzbDrone.Core.Update.Commands { public override bool SendUpdatesToClient => true; - public override string CompletionMessage => "Restarting Sonarr to apply updates"; + public override string CompletionMessage => null; } } diff --git a/src/NzbDrone.Core/Update/InstallUpdateService.cs b/src/NzbDrone.Core/Update/InstallUpdateService.cs index 5ca1c31fb..e798ff58a 100644 --- a/src/NzbDrone.Core/Update/InstallUpdateService.cs +++ b/src/NzbDrone.Core/Update/InstallUpdateService.cs @@ -199,19 +199,20 @@ namespace NzbDrone.Core.Update if (latestAvailable == null) { - _logger.ProgressDebug("No update available."); + _logger.ProgressDebug("No update available"); return; } if (OsInfo.IsNotWindows && !_configFileProvider.UpdateAutomatically && message.Trigger != CommandTrigger.Manual) { - _logger.ProgressDebug("Auto-update not enabled, not installing available update."); + _logger.ProgressDebug("Auto-update not enabled, not installing available update"); return; } try { InstallUpdate(latestAvailable); + _logger.ProgressDebug("Restarting Sonarr to apply updates"); } catch (UpdateFolderNotWritableException ex) { diff --git a/src/NzbDrone.Core/Validation/RuleBuilderExtensions.cs b/src/NzbDrone.Core/Validation/RuleBuilderExtensions.cs index df1d8056b..2fae03d6c 100644 --- a/src/NzbDrone.Core/Validation/RuleBuilderExtensions.cs +++ b/src/NzbDrone.Core/Validation/RuleBuilderExtensions.cs @@ -34,9 +34,9 @@ namespace NzbDrone.Core.Validation return ruleBuilder.SetValidator(new RegularExpressionValidator("^https?://[-_a-z0-9.]+", RegexOptions.IgnoreCase)).WithMessage("must be valid URL that starts with http(s)://"); } - public static IRuleBuilderOptions<T, string> ValidUrlBase<T>(this IRuleBuilder<T, string> ruleBuilder) + public static IRuleBuilderOptions<T, string> ValidUrlBase<T>(this IRuleBuilder<T, string> ruleBuilder, string example = "/sonarr") { - return ruleBuilder.SetValidator(new RegularExpressionValidator(@"^(?!\/?https?://[-_a-z0-9.]+)", RegexOptions.IgnoreCase)).WithMessage("Must be a valid URL path (ie: '/sonarr')"); + return ruleBuilder.SetValidator(new RegularExpressionValidator(@"^(?!\/?https?://[-_a-z0-9.]+)", RegexOptions.IgnoreCase)).WithMessage($"Must be a valid URL path (ie: '{example}')"); } public static IRuleBuilderOptions<T, int> ValidPort<T>(this IRuleBuilder<T, int> ruleBuilder) @@ -68,4 +68,4 @@ namespace NzbDrone.Core.Validation return ruleBuilder.WithState(v => NzbDroneValidationState.Warning); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Host/Bootstrap.cs b/src/NzbDrone.Host/Bootstrap.cs index 24a151eeb..39244c28a 100644 --- a/src/NzbDrone.Host/Bootstrap.cs +++ b/src/NzbDrone.Host/Bootstrap.cs @@ -4,9 +4,11 @@ using System.Threading; using NLog; using NzbDrone.Common.Composition; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Exceptions; using NzbDrone.Common.Instrumentation; using NzbDrone.Common.Processes; using NzbDrone.Common.Security; +using NzbDrone.Core.Configuration; using NzbDrone.Core.Datastore; using NzbDrone.Core.Instrumentation; @@ -49,9 +51,13 @@ namespace NzbDrone.Host SpinToExit(appMode); } } - catch (TerminateApplicationException e) + catch (InvalidConfigFileException ex) { - Logger.Info(e.Message); + throw new SonarrStartupException(ex); + } + catch (TerminateApplicationException ex) + { + Logger.Info(ex.Message); LogManager.Configuration = null; } } @@ -88,11 +94,15 @@ namespace NzbDrone.Host { var instancePolicy = _container.Resolve<ISingleInstancePolicy>(); - if (isService) + if (startupContext.Flags.Contains(StartupContext.TERMINATE)) { instancePolicy.KillAllOtherInstance(); } - else if (startupContext.Flags.Contains(StartupContext.TERMINATE)) + else if (startupContext.Args.ContainsKey(StartupContext.APPDATA)) + { + instancePolicy.WarnIfAlreadyRunning(); + } + else if (isService) { instancePolicy.KillAllOtherInstance(); } diff --git a/src/NzbDrone.Host/SingleInstancePolicy.cs b/src/NzbDrone.Host/SingleInstancePolicy.cs index 75b8bb13e..a24f12207 100644 --- a/src/NzbDrone.Host/SingleInstancePolicy.cs +++ b/src/NzbDrone.Host/SingleInstancePolicy.cs @@ -10,6 +10,7 @@ namespace NzbDrone.Host { void PreventStartIfAlreadyRunning(); void KillAllOtherInstance(); + void WarnIfAlreadyRunning(); } public class SingleInstancePolicy : ISingleInstancePolicy @@ -45,6 +46,14 @@ namespace NzbDrone.Host } } + public void WarnIfAlreadyRunning() + { + if (IsAlreadyRunning()) + { + _logger.Debug("Another instance of Sonarr is already running."); + } + } + private bool IsAlreadyRunning() { return GetOtherNzbDroneProcessIds().Any(); @@ -76,4 +85,4 @@ namespace NzbDrone.Host } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Integration.Test/ApiTests/ReleasePushFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/ReleasePushFixture.cs new file mode 100644 index 000000000..92c650be3 --- /dev/null +++ b/src/NzbDrone.Integration.Test/ApiTests/ReleasePushFixture.cs @@ -0,0 +1,31 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Api.Indexers; +using System.Linq; +using System.Net; +using System.Collections.Generic; +using System; +using System.Globalization; + +namespace NzbDrone.Integration.Test.ApiTests +{ + [TestFixture] + public class ReleasePushFixture : IntegrationTest + { + [Test] + public void should_have_utc_date() + { + var body = new Dictionary<string, object>(); + body.Add("guid", "sdfsdfsdf"); + body.Add("title", "The.Series.S01E01"); + body.Add("publishDate", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ssZ", CultureInfo.InvariantCulture)); + + var request = ReleasePush.BuildRequest(); + request.AddBody(body); + var result = ReleasePush.Post<ReleaseResource>(request, HttpStatusCode.OK); + + result.Should().NotBeNull(); + result.AgeHours.Should().BeApproximately(0, 0.1); + } + } +} diff --git a/src/NzbDrone.Integration.Test/Client/ReleasePushClient.cs b/src/NzbDrone.Integration.Test/Client/ReleasePushClient.cs new file mode 100644 index 000000000..9ea6ccce9 --- /dev/null +++ b/src/NzbDrone.Integration.Test/Client/ReleasePushClient.cs @@ -0,0 +1,13 @@ +using NzbDrone.Api.Indexers; +using RestSharp; + +namespace NzbDrone.Integration.Test.Client +{ + public class ReleasePushClient : ClientBase<ReleaseResource> + { + public ReleasePushClient(IRestClient restClient, string apiKey) + : base(restClient, apiKey, "release/push") + { + } + } +} diff --git a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs index cf6593d04..16b6681c8 100644 --- a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs +++ b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs @@ -48,6 +48,7 @@ namespace NzbDrone.Integration.Test public NotificationClient Notifications; public ClientBase<ProfileResource> Profiles; public ReleaseClient Releases; + public ReleasePushClient ReleasePush; public ClientBase<RootFolderResource> RootFolders; public SeriesClient Series; public ClientBase<TagResource> Tags; @@ -108,6 +109,7 @@ namespace NzbDrone.Integration.Test Notifications = new NotificationClient(RestClient, ApiKey); Profiles = new ClientBase<ProfileResource>(RestClient, ApiKey); Releases = new ReleaseClient(RestClient, ApiKey); + ReleasePush = new ReleasePushClient(RestClient, ApiKey); RootFolders = new ClientBase<RootFolderResource>(RestClient, ApiKey); Series = new SeriesClient(RestClient, ApiKey); Tags = new ClientBase<TagResource>(RestClient, ApiKey); @@ -272,7 +274,7 @@ namespace NzbDrone.Integration.Test Commands.PostAndWait(new CommandResource { Name = "refreshseries", Body = new RefreshSeriesCommand(series.Id) }); Commands.WaitAll(); - + result = Episodes.GetEpisodesInSeries(series.Id).Single(v => v.SeasonNumber == season && v.EpisodeNumber == episode); result.EpisodeFile.Should().NotBeNull(); diff --git a/src/NzbDrone.Integration.Test/NzbDrone.Integration.Test.csproj b/src/NzbDrone.Integration.Test/NzbDrone.Integration.Test.csproj index af8196a53..fc5d16225 100644 --- a/src/NzbDrone.Integration.Test/NzbDrone.Integration.Test.csproj +++ b/src/NzbDrone.Integration.Test/NzbDrone.Integration.Test.csproj @@ -105,6 +105,7 @@ <Compile Include="ApiTests\DownloadClientFixture.cs" /> <Compile Include="ApiTests\EpisodeFileFixture.cs" /> <Compile Include="ApiTests\FileSystemFixture.cs" /> + <Compile Include="ApiTests\ReleasePushFixture.cs" /> <Compile Include="ApiTests\SeriesLookupFixture.cs" /> <Compile Include="ApiTests\WantedFixture.cs" /> <Compile Include="Client\ClientBase.cs" /> @@ -113,6 +114,7 @@ <Compile Include="Client\DownloadClientClient.cs" /> <Compile Include="Client\NotificationClient.cs" /> <Compile Include="Client\CommandClient.cs" /> + <Compile Include="Client\ReleasePushClient.cs" /> <Compile Include="Client\ReleaseClient.cs" /> <Compile Include="Client\SeriesClient.cs" /> <Compile Include="ApiTests\CommandFixture.cs" /> diff --git a/src/NzbDrone.Mono/Disk/DiskProvider.cs b/src/NzbDrone.Mono/Disk/DiskProvider.cs index d9f3cba29..27bdaf60c 100644 --- a/src/NzbDrone.Mono/Disk/DiskProvider.cs +++ b/src/NzbDrone.Mono/Disk/DiskProvider.cs @@ -86,11 +86,14 @@ namespace NzbDrone.Mono.Disk public override List<IMount> GetMounts() { - return GetDriveInfoMounts().Select(d => new DriveInfoMount(d, FindDriveType.Find(d.DriveFormat))) - .Where(d => d.DriveType == DriveType.Fixed || d.DriveType == DriveType.Network || d.DriveType == DriveType.Removable) - .Concat(_procMountProvider.GetMounts()) - .DistinctBy(v => v.RootDirectory) - .ToList(); + return _procMountProvider.GetMounts() + .Concat(GetDriveInfoMounts() + .Select(d => new DriveInfoMount(d, FindDriveType.Find(d.DriveFormat))) + .Where(d => d.DriveType == DriveType.Fixed || + d.DriveType == DriveType.Network || d.DriveType == + DriveType.Removable)) + .DistinctBy(v => v.RootDirectory) + .ToList(); } public override long? GetTotalSize(string path) diff --git a/src/NzbDrone.Mono/Disk/ProcMount.cs b/src/NzbDrone.Mono/Disk/ProcMount.cs index 87e428112..af3e59bca 100644 --- a/src/NzbDrone.Mono/Disk/ProcMount.cs +++ b/src/NzbDrone.Mono/Disk/ProcMount.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using Mono.Unix; using NzbDrone.Common.Disk; @@ -10,12 +10,13 @@ namespace NzbDrone.Mono.Disk { private readonly UnixDriveInfo _unixDriveInfo; - public ProcMount(DriveType driveType, string name, string mount, string type, Dictionary<string, string> options) + public ProcMount(DriveType driveType, string name, string mount, string type, MountOptions mountOptions) { DriveType = driveType; Name = name; RootDirectory = mount; DriveFormat = type; + MountOptions = mountOptions; _unixDriveInfo = new UnixDriveInfo(mount); } @@ -28,6 +29,8 @@ namespace NzbDrone.Mono.Disk public bool IsReady => _unixDriveInfo.IsReady; + public MountOptions MountOptions { get; private set; } + public string Name { get; private set; } public string RootDirectory { get; private set; } @@ -42,7 +45,7 @@ namespace NzbDrone.Mono.Disk { get { - if (VolumeLabel.IsNullOrWhiteSpace()) + if (VolumeLabel.IsNullOrWhiteSpace() || VolumeLabel.StartsWith("UUID=")) { return Name; } diff --git a/src/NzbDrone.Mono/Disk/ProcMountProvider.cs b/src/NzbDrone.Mono/Disk/ProcMountProvider.cs index caa9cc467..ded2d2b36 100644 --- a/src/NzbDrone.Mono/Disk/ProcMountProvider.cs +++ b/src/NzbDrone.Mono/Disk/ProcMountProvider.cs @@ -130,7 +130,7 @@ namespace NzbDrone.Mono.Disk driveType = DriveType.Network; } - return new ProcMount(driveType, name, mount, type, options); + return new ProcMount(driveType, name, mount, type, new MountOptions(options)); } private Dictionary<string, string> ParseOptions(string options) diff --git a/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs b/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs index dfa063a0e..b3342ffba 100644 --- a/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs +++ b/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs @@ -1,5 +1,7 @@ -using Microsoft.AspNet.SignalR; +using Microsoft.AspNet.SignalR; using Microsoft.AspNet.SignalR.Infrastructure; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; namespace NzbDrone.SignalR { @@ -12,9 +14,28 @@ namespace NzbDrone.SignalR { private IPersistentConnectionContext Context => ((ConnectionManager)GlobalHost.ConnectionManager).GetConnection(GetType()); + private static string API_KEY; + + public NzbDronePersistentConnection(IConfigFileProvider configFileProvider) + { + API_KEY = configFileProvider.ApiKey; + } + public void BroadcastMessage(SignalRMessage message) { Context.Connection.Broadcast(message); } + + protected override bool AuthorizeRequest(IRequest request) + { + var apiKey = request.QueryString["apiKey"]; + + if (apiKey.IsNotNullOrWhiteSpace() && apiKey.Equals(API_KEY)) + { + return true; + } + + return false; + } } } \ No newline at end of file diff --git a/src/NzbDrone.Test.Common/App.config b/src/NzbDrone.Test.Common/App.config index 886337c3a..c7a8ca18c 100644 --- a/src/NzbDrone.Test.Common/App.config +++ b/src/NzbDrone.Test.Common/App.config @@ -21,6 +21,10 @@ <assemblyIdentity name="Microsoft.Practices.Unity" publicKeyToken="31bf3856ad364e35" culture="neutral" /> <bindingRedirect oldVersion="0.0.0.0-2.1.505.0" newVersion="2.1.505.0" /> </dependentAssembly> + <dependentAssembly> + <assemblyIdentity name="Microsoft.Practices.ServiceLocation" publicKeyToken="31bf3856ad364e35" culture="neutral" /> + <bindingRedirect oldVersion="0.0.0.0-1.3.0.0" newVersion="1.3.0.0" /> + </dependentAssembly> </assemblyBinding> </runtime> </configuration> \ No newline at end of file diff --git a/src/NzbDrone.Test.Common/NzbDrone.Test.Common.csproj b/src/NzbDrone.Test.Common/NzbDrone.Test.Common.csproj index 08bf03d3f..addbe954c 100644 --- a/src/NzbDrone.Test.Common/NzbDrone.Test.Common.csproj +++ b/src/NzbDrone.Test.Common/NzbDrone.Test.Common.csproj @@ -43,6 +43,9 @@ <Reference Include="FluentAssertions.Core, Version=4.19.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> <HintPath>..\packages\FluentAssertions.4.19.0\lib\net40\FluentAssertions.Core.dll</HintPath> </Reference> + <Reference Include="FluentValidation, Version=6.2.1.0, Culture=neutral, processorArchitecture=MSIL"> + <HintPath>..\packages\FluentValidation.6.2.1.0\lib\portable-net40+sl50+wp80+win8+wpa81\FluentValidation.dll</HintPath> + </Reference> <Reference Include="Newtonsoft.Json, Version=9.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> <HintPath>..\packages\Newtonsoft.Json.9.0.1\lib\net40\Newtonsoft.Json.dll</HintPath> <Private>True</Private> @@ -94,6 +97,7 @@ <Compile Include="StringExtensions.cs" /> <Compile Include="TestBase.cs" /> <Compile Include="TestException.cs" /> + <Compile Include="TestValidator.cs" /> </ItemGroup> <ItemGroup> <Content Include="AutoMoq\License.txt" /> diff --git a/src/NzbDrone.Test.Common/TestValidator.cs b/src/NzbDrone.Test.Common/TestValidator.cs new file mode 100644 index 000000000..2801eb442 --- /dev/null +++ b/src/NzbDrone.Test.Common/TestValidator.cs @@ -0,0 +1,16 @@ +using System; +using FluentValidation; + +namespace NzbDrone.Test.Common +{ + public class TestValidator<T> : InlineValidator<T> + { + public TestValidator(params Action<TestValidator<T>>[] actions) + { + foreach (var action in actions) + { + action(this); + } + } + } +} diff --git a/src/NzbDrone.Test.Common/packages.config b/src/NzbDrone.Test.Common/packages.config index ea89c79fc..2a00b679d 100644 --- a/src/NzbDrone.Test.Common/packages.config +++ b/src/NzbDrone.Test.Common/packages.config @@ -2,6 +2,7 @@ <packages> <package id="CommonServiceLocator" version="1.3" targetFramework="net40" /> <package id="FluentAssertions" version="4.19.0" targetFramework="net40" /> + <package id="FluentValidation" version="6.2.1.0" targetFramework="net40" /> <package id="Moq" version="4.0.10827" /> <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net40" /> <package id="NLog" version="4.4.3" targetFramework="net40" /> diff --git a/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs index 9c2866330..2f9c04c2d 100644 --- a/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs +++ b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs @@ -110,9 +110,6 @@ namespace NzbDrone.Update.UpdateEngine try { - _logger.Info("Emptying installation folder"); - _diskProvider.EmptyFolder(installationFolder); - _logger.Info("Copying new files to target folder"); _diskTransferService.MirrorFolder(_appFolderInfo.GetUpdatePackageFolder(), installationFolder); diff --git a/src/UI/Activity/History/Details/HistoryDetailsLayoutTemplate.hbs b/src/UI/Activity/History/Details/HistoryDetailsLayoutTemplate.hbs index 892dbfc35..ec1181c90 100644 --- a/src/UI/Activity/History/Details/HistoryDetailsLayoutTemplate.hbs +++ b/src/UI/Activity/History/Details/HistoryDetailsLayoutTemplate.hbs @@ -8,6 +8,7 @@ {{#if_eq eventType compare="downloadFailed"}}Download Failed{{/if_eq}} {{#if_eq eventType compare="downloadFolderImported"}}Episode Imported{{/if_eq}} {{#if_eq eventType compare="episodeFileDeleted"}}Episode File Deleted{{/if_eq}} + {{#if_eq eventType compare="episodeFileRenamed"}}Episode File Renamed{{/if_eq}} </h3> </div> diff --git a/src/UI/Activity/History/Details/HistoryDetailsViewTemplate.hbs b/src/UI/Activity/History/Details/HistoryDetailsViewTemplate.hbs index 89a757660..b70b8e4cb 100644 --- a/src/UI/Activity/History/Details/HistoryDetailsViewTemplate.hbs +++ b/src/UI/Activity/History/Details/HistoryDetailsViewTemplate.hbs @@ -100,4 +100,23 @@ </dd> {{/with}} </dl> -{{/if_eq}} \ No newline at end of file +{{/if_eq}} + +{{#if_eq eventType compare="episodeFileRenamed"}} +<dl class="dl-horizontal"> + + <dt>Source Path:</dt> + <dd>{{sourceTitle}}</dd> + + {{#with data}} + <dt>Source Relative Path:</dt> + <dd>{{sourceRelativePath}}</dd> + + <dt>Path:</dt> + <dd>{{path}}</dd> + + <dt>Relative Path:</dt> + <dd>{{relativePath}}</dd> + {{/with}} +</dl> +{{/if_eq}} diff --git a/src/UI/Activity/History/HistoryCollection.js b/src/UI/Activity/History/HistoryCollection.js index 3bd564309..661e2221a 100644 --- a/src/UI/Activity/History/HistoryCollection.js +++ b/src/UI/Activity/History/HistoryCollection.js @@ -46,6 +46,10 @@ var Collection = PageableCollection.extend({ 'deleted' : [ 'eventType', '5' + ], + 'renamed' : [ + 'eventType', + '6' ] }, @@ -80,4 +84,4 @@ Collection = AsFilteredCollection.call(Collection); Collection = AsSortedCollection.call(Collection); Collection = AsPersistedStateCollection.call(Collection); -module.exports = Collection; \ No newline at end of file +module.exports = Collection; diff --git a/src/UI/Activity/History/HistoryLayout.js b/src/UI/Activity/History/HistoryLayout.js index ae7e4c93e..29d6f44af 100644 --- a/src/UI/Activity/History/HistoryLayout.js +++ b/src/UI/Activity/History/HistoryLayout.js @@ -129,6 +129,13 @@ module.exports = Marionette.Layout.extend({ tooltip : 'Deleted', icon : 'icon-sonarr-deleted', callback : this._setFilter + }, + { + key : 'renamed', + title : '', + tooltip : 'Renamed', + icon : 'icon-sonarr-rename', + callback : this._setFilter } ] }; diff --git a/src/UI/Activity/Queue/QueueActionsCell.js b/src/UI/Activity/Queue/QueueActionsCell.js index eb2297fda..ad3f75144 100644 --- a/src/UI/Activity/Queue/QueueActionsCell.js +++ b/src/UI/Activity/Queue/QueueActionsCell.js @@ -23,7 +23,8 @@ module.exports = TemplatedCell.extend({ }, _remove : function() { - var showBlacklist = this.model.get('status') !== 'Pending'; + var status = this.model.get('status'); + var showBlacklist = status !== 'Delay' && status !== 'DownloadClientUnavailable'; vent.trigger(vent.Commands.OpenModalCommand, new RemoveFromQueueView({ model : this.model, diff --git a/src/UI/Activity/Queue/QueueActionsCellTemplate.hbs b/src/UI/Activity/Queue/QueueActionsCellTemplate.hbs index 13bee034e..01e5b34ab 100644 --- a/src/UI/Activity/Queue/QueueActionsCellTemplate.hbs +++ b/src/UI/Activity/Queue/QueueActionsCellTemplate.hbs @@ -4,9 +4,16 @@ {{/if_eq}} {{/if_eq}} -{{#if_eq status compare="Pending"}} +{{#if_eq status compare="Delay"}} <i class="icon-sonarr-download x-grab" title="Add to download queue (Override Delay Profile)"></i> <i class="icon-sonarr-delete x-remove" title="Remove pending release"></i> {{else}} - <i class="icon-sonarr-delete x-remove" title="Remove from download client"></i> + {{#unless_eq status compare="DownloadClientUnavailable"}} + <i class="icon-sonarr-delete x-remove" title="Remove from download client"></i> + {{/unless_eq}} +{{/if_eq}} + +{{#if_eq status compare="DownloadClientUnavailable"}} + <i class="icon-sonarr-download x-grab" title="Add to download queue (Retry failed download)"></i> + <i class="icon-sonarr-delete x-remove" title="Remove pending release"></i> {{/if_eq}} diff --git a/src/UI/Activity/Queue/QueueStatusCell.js b/src/UI/Activity/Queue/QueueStatusCell.js index 04c027b50..42cad2467 100644 --- a/src/UI/Activity/Queue/QueueStatusCell.js +++ b/src/UI/Activity/Queue/QueueStatusCell.js @@ -1,5 +1,8 @@ var Marionette = require('marionette'); var NzbDroneCell = require('../../Cells/NzbDroneCell'); +var moment = require('moment'); +var UiSettingsModel = require('../../Shared/UiSettingsModel'); +var FormatHelpers = require('../../Shared/FormatHelpers'); module.exports = NzbDroneCell.extend({ className : 'queue-status-cell', @@ -31,9 +34,16 @@ module.exports = NzbDroneCell.extend({ title = 'Downloaded'; } - if (status === 'pending') { + if (status === 'delay') { icon = 'icon-sonarr-pending'; - title = 'Pending'; + var ect = this.cellValue.get('estimatedCompletionTime'); + var time = '{0} at {1}'.format(FormatHelpers.relativeDate(ect), moment(ect).format(UiSettingsModel.time(true, false))); + title = 'Download delayed till {0}'.format(time); + } + + if (status === 'downloadclientunavailable') { + icon = 'icon-sonarr-client-unavailable'; + title = 'Download pending, download client is unavailable'; } if (status === 'failed') { diff --git a/src/UI/Activity/Queue/TimeleftCell.js b/src/UI/Activity/Queue/TimeleftCell.js index 766d9df2d..145afceb2 100644 --- a/src/UI/Activity/Queue/TimeleftCell.js +++ b/src/UI/Activity/Queue/TimeleftCell.js @@ -10,24 +10,39 @@ module.exports = NzbDroneCell.extend({ this.$el.empty(); if (this.cellValue) { - if (this.cellValue.get('status').toLowerCase() === 'pending') { - var ect = this.cellValue.get('estimatedCompletionTime'); - var time = '{0} at {1}'.format(FormatHelpers.relativeDate(ect), moment(ect).format(UiSettingsModel.time(true, false))); + var status = this.cellValue.get('status').toLowerCase(); + var ect = this.cellValue.get('estimatedCompletionTime'); + var time = '{0} at {1}'.format(FormatHelpers.relativeDate(ect), moment(ect).format(UiSettingsModel.time(true, false))); + + if (status === 'delay') { this.$el.html('<div title="Delaying download till {0}">-</div>'.format(time)); - return this; - } - - var timeleft = this.cellValue.get('timeleft'); - var totalSize = FormatHelpers.bytes(this.cellValue.get('size'), 2); - var remainingSize = FormatHelpers.bytes(this.cellValue.get('sizeleft'), 2); - - if (timeleft === undefined) { - this.$el.html('-'); + } else if (status === 'downloadclientunavailable') { + this.$el.html('<div title="Retrying download at {0}">-</div>'.format(time)); } else { - this.$el.html('<span title="{1} / {2}">{0}</span>'.format(timeleft, remainingSize, totalSize)); + var timeleft = this.cellValue.get('timeleft'); + var totalSize = FormatHelpers.bytes(this.cellValue.get('size'), 2); + var remainingSize = FormatHelpers.bytes(this.cellValue.get('sizeleft'), 2); + + if (timeleft === undefined) { + this.$el.html('-'); + } else { + var duration = moment.duration(timeleft); + var days = duration.get('days'); + var hours = FormatHelpers.pad(duration.get('hours'), 2); + var minutes = FormatHelpers.pad(duration.get('minutes'), 2); + var seconds = FormatHelpers.pad(duration.get('seconds'), 2); + + var formattedTime = '{0}:{1}:{2}'.format(hours, minutes, seconds); + + if (days > 0) { + formattedTime = days + 'd ' + formattedTime; + } + + this.$el.html('<span title="{1} / {2}">{0}</span>'.format(formattedTime, remainingSize, totalSize)); + } } } return this; } -}); \ No newline at end of file +}); diff --git a/src/UI/Cells/DeleteEpisodeFileCell.js b/src/UI/Cells/DeleteEpisodeFileCell.js index 88ddf8b82..6266a41bb 100644 --- a/src/UI/Cells/DeleteEpisodeFileCell.js +++ b/src/UI/Cells/DeleteEpisodeFileCell.js @@ -19,9 +19,9 @@ module.exports = Backgrid.Cell.extend({ var self = this; if (window.confirm('Are you sure you want to delete \'{0}\' from disk?'.format(this.model.get('path')))) { - this.model.destroy().done(function() { + this.model.destroy({ wait: true }).done(function() { vent.trigger(vent.Events.EpisodeFileDeleted, { episodeFile : self.model }); }); } } -}); \ No newline at end of file +}); diff --git a/src/UI/Cells/EventTypeCell.js b/src/UI/Cells/EventTypeCell.js index 4ca9a85ae..1b374d67f 100644 --- a/src/UI/Cells/EventTypeCell.js +++ b/src/UI/Cells/EventTypeCell.js @@ -31,6 +31,10 @@ module.exports = NzbDroneCell.extend({ icon = 'icon-sonarr-deleted'; toolTip = 'Episode file deleted'; break; + case 'episodeFileRenamed': + icon = 'icon-sonarr-rename'; + toolTip = 'Episode file renamed'; + break; default: icon = 'icon-sonarr-unknown'; toolTip = 'unknown event'; @@ -41,4 +45,4 @@ module.exports = NzbDroneCell.extend({ return this; } -}); \ No newline at end of file +}); diff --git a/src/UI/Content/icons.less b/src/UI/Content/icons.less index cce09293a..3f41edb40 100644 --- a/src/UI/Content/icons.less +++ b/src/UI/Content/icons.less @@ -151,6 +151,11 @@ .fa-icon-content(@fa-var-clock-o); } +.icon-sonarr-client-unavailable { + .fa-icon-content(@fa-var-clock-o); + .fa-icon-color(@brand-warning); +} + .icon-sonarr-queued { .fa-icon-content(@fa-var-cloud); } @@ -502,4 +507,4 @@ .icon-sonarr-header-rejections { .fa-icon-content(@fa-var-exclamation-circle); -} \ No newline at end of file +} diff --git a/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayout.js b/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayout.js index a974c8f7c..46eb0638d 100644 --- a/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayout.js +++ b/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayout.js @@ -176,7 +176,7 @@ module.exports = Marionette.Layout.extend({ if (reqres.hasHandler(reqres.Requests.GetEpisodeFileById)) { var episodeFile = reqres.request(reqres.Requests.GetEpisodeFileById, episodeFileId); - episodeFile.destroy(); + episodeFile.destroy({ wait: true }); } }); diff --git a/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayoutTemplate.hbs b/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayoutTemplate.hbs index 6f7e84109..1f5213660 100644 --- a/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayoutTemplate.hbs +++ b/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayoutTemplate.hbs @@ -21,7 +21,7 @@ <div class="x-quality"></div> </div> <div class="modal-footer"> - <button class="btn btn-danger x-delete-files">Delete Files</button> + <button class="btn btn-danger pull-left x-delete-files">Delete Files</button> <button class="btn btn-default" data-dismiss="modal">Close</button> </div> </div> diff --git a/src/UI/Form/CheckboxTemplate.hbs b/src/UI/Form/CheckboxTemplate.hbs index d3803ab70..526804714 100644 --- a/src/UI/Form/CheckboxTemplate.hbs +++ b/src/UI/Form/CheckboxTemplate.hbs @@ -4,7 +4,7 @@ <div class="col-sm-5"> <div class="input-group"> <label class="checkbox toggle well"> - <input type="checkbox" name="fields.{{order}}.value"/> + <input type="checkbox" name="fields.{{order}}.value" validation-name="{{name}}"/> <p> <span>Yes</span> <span>No</span> diff --git a/src/UI/Form/SelectTemplate.hbs b/src/UI/Form/SelectTemplate.hbs index 978d432df..afdffbe38 100644 --- a/src/UI/Form/SelectTemplate.hbs +++ b/src/UI/Form/SelectTemplate.hbs @@ -2,7 +2,7 @@ <label class="col-sm-3 control-label">{{label}}</label> <div class="col-sm-5"> - <select name="fields.{{order}}.value" class="form-control"> + <select name="fields.{{order}}.value" validation-name="{{name}}" class="form-control"> {{#each selectOptions}} <option value="{{value}}">{{name}}</option> {{/each}} diff --git a/src/UI/JsLibraries/jdu.js b/src/UI/JsLibraries/jdu.js new file mode 100644 index 000000000..22e37b9e4 --- /dev/null +++ b/src/UI/JsLibraries/jdu.js @@ -0,0 +1,302 @@ +/** + * Credit to creator(s) of original logic found at: + * http://jsperf.com/diacritics + * + * Created by Shaun DeVos 'mk7upurz87' on 3/6/17. + */ + +'use strict'; + +(function () { + function Diacritics() { + + var latinDiacritics = [ + { + 'base': 'A', + 'letters': '\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F' + }, { + 'base': 'AA', + 'letters': '\uA732' + }, { + 'base': 'AE', + 'letters': '\u00C6\u01FC\u01E2' + }, { + 'base': 'AO', + 'letters': '\uA734' + }, { + 'base': 'AU', + 'letters': '\uA736' + }, { + 'base': 'AV', + 'letters': '\uA738\uA73A' + }, { + 'base': 'AY', + 'letters': '\uA73C' + }, { + 'base': 'B', + 'letters': '\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181' + }, { + 'base': 'C', + 'letters': '\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E' + }, { + 'base': 'D', + 'letters': '\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779' + }, { + 'base': 'DZ', + 'letters': '\u01F1\u01C4' + }, { + 'base': 'Dz', + 'letters': '\u01F2\u01C5' + }, { + 'base': 'E', + 'letters': '\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E' + }, { + 'base': 'F', + 'letters': '\u0046\u24BB\uFF26\u1E1E\u0191\uA77B' + }, { + 'base': 'G', + 'letters': '\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E' + }, { + 'base': 'H', + 'letters': '\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D' + }, { + 'base': 'I', + 'letters': '\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197' + }, { + 'base': 'J', + 'letters': '\u004A\u24BF\uFF2A\u0134\u0248' + }, { + 'base': 'K', + 'letters': '\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2' + }, { + 'base': 'L', + 'letters': '\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780' + }, { + 'base': 'LJ', + 'letters': '\u01C7' + }, { + 'base': 'Lj', + 'letters': '\u01C8' + }, { + 'base': 'M', + 'letters': '\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C' + }, { + 'base': 'N', + 'letters': '\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4' + }, { + 'base': 'NJ', + 'letters': '\u01CA' + }, { + 'base': 'Nj', + 'letters': '\u01CB' + }, { + 'base': 'O', + 'letters': '\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C' + }, { + 'base': 'OI', + 'letters': '\u01A2' + }, { + 'base': 'OO', + 'letters': '\uA74E' + }, { + 'base': 'OU', + 'letters': '\u0222' + }, { + 'base': 'P', + 'letters': '\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754' + }, { + 'base': 'Q', + 'letters': '\u0051\u24C6\uFF31\uA756\uA758\u024A' + }, { + 'base': 'R', + 'letters': '\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782' + }, { + 'base': 'S', + 'letters': '\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784' + }, { + 'base': 'T', + 'letters': '\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786' + }, { + 'base': 'TZ', + 'letters': '\uA728' + }, { + 'base': 'U', + 'letters': '\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244' + }, { + 'base': 'V', + 'letters': '\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245' + }, { + 'base': 'VY', + 'letters': '\uA760' + }, { + 'base': 'W', + 'letters': '\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72' + }, { + 'base': 'X', + 'letters': '\u0058\u24CD\uFF38\u1E8A\u1E8C' + }, { + 'base': 'Y', + 'letters': '\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE' + }, { + 'base': 'Z', + 'letters': '\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762' + }, { + 'base': 'a', + 'letters': '\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u00E4\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250' + }, { + 'base': 'aa', + 'letters': '\uA733' + }, { + 'base': 'ae', + 'letters': '\u00E6\u01FD\u01E3' + }, { + 'base': 'ao', + 'letters': '\uA735' + }, { + 'base': 'au', + 'letters': '\uA737' + }, { + 'base': 'av', + 'letters': '\uA739\uA73B' + }, { + 'base': 'ay', + 'letters': '\uA73D' + }, { + 'base': 'b', + 'letters': '\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253' + }, { + 'base': 'c', + 'letters': '\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184' + }, { + 'base': 'd', + 'letters': '\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A' + }, { + 'base': 'dz', + 'letters': '\u01F3\u01C6' + }, { + 'base': 'e', + 'letters': '\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD' + }, { + 'base': 'f', + 'letters': '\u0066\u24D5\uFF46\u1E1F\u0192\uA77C' + }, { + 'base': 'g', + 'letters': '\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F' + }, { + 'base': 'h', + 'letters': '\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265' + }, { + 'base': 'hv', + 'letters': '\u0195' + }, { + 'base': 'i', + 'letters': '\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131' + }, { + 'base': 'j', + 'letters': '\u006A\u24D9\uFF4A\u0135\u01F0\u0249' + }, { + 'base': 'k', + 'letters': '\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3' + }, { + 'base': 'l', + 'letters': '\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747' + }, { + 'base': 'lj', + 'letters': '\u01C9' + }, { + 'base': 'm', + 'letters': '\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F' + }, { + 'base': 'n', + 'letters': '\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5' + }, { + 'base': 'nj', + 'letters': '\u01CC' + }, { + 'base': 'o', + 'letters': '\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u00F6\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275' + }, { + 'base': 'oi', + 'letters': '\u01A3' + }, { + 'base': 'ou', + 'letters': '\u0223' + }, { + 'base': 'oo', + 'letters': '\uA74F' + }, { + 'base': 'p', + 'letters': '\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755' + }, { + 'base': 'q', + 'letters': '\u0071\u24E0\uFF51\u024B\uA757\uA759' + }, { + 'base': 'r', + 'letters': '\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783' + }, { + 'base': 's', + 'letters': '\u0073\u24E2\uFF53\u00DF\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B' + }, { + 'base': 't', + 'letters': '\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787' + }, { + 'base': 'tz', + 'letters': '\uA729' + }, { + 'base': 'u', + 'letters': '\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u00FC\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289' + }, { + 'base': 'v', + 'letters': '\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C' + }, { + 'base': 'vy', + 'letters': '\uA761' + }, { + 'base': 'w', + 'letters': '\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73' + }, { + 'base': 'x', + 'letters': '\u0078\u24E7\uFF58\u1E8B\u1E8D' + }, { + 'base': 'y', + 'letters': '\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF' + }, { + 'base': 'z', + 'letters': '\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763' + } + ]; + + var diacriticsMap = {}; + var charCodeMap = {}; + for (var diacriticIndex = 0; diacriticIndex < latinDiacritics.length; diacriticIndex++) { + + var letters = latinDiacritics[diacriticIndex].letters.split(""); + for (var letterIndex = 0; letterIndex < letters.length; letterIndex++) { + diacriticsMap[letters[letterIndex]] = latinDiacritics[diacriticIndex].base; + charCodeMap[letters[letterIndex].charCodeAt(0)] = latinDiacritics[diacriticIndex].base; + } + } + + function replace(string) { + return string.replace(/[^\u0000-\u007E]/g, function (a) { + return diacriticsMap[a] || a; + }); + } + + function isLatin(string) { + return string === this.replace(string); + } + + return { + replace: replace, + isLatin: isLatin + }; + } + + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = Diacritics(); + } + else { + window.Diacritics = Diacritics(); + } +})(); diff --git a/src/UI/Navbar/Search.js b/src/UI/Navbar/Search.js index ec1e14ead..f4dddf432 100644 --- a/src/UI/Navbar/Search.js +++ b/src/UI/Navbar/Search.js @@ -2,17 +2,23 @@ var _ = require('underscore'); var $ = require('jquery'); var vent = require('vent'); var Backbone = require('backbone'); +var jdu = require('jdu'); var SeriesCollection = require('../Series/SeriesCollection'); require('typeahead'); + vent.on(vent.Hotkeys.NavbarSearch, function() { $('.x-series-search').focus(); }); +var stringCleaner = function(text) { + return jdu.replace(text.toLowerCase()); +}; + var substringMatcher = function() { return function findMatches (q, cb) { var matches = _.select(SeriesCollection.toJSON(), function(series) { - return series.title.toLowerCase().indexOf(q.toLowerCase()) > -1; + return stringCleaner(series.title).indexOf(stringCleaner(q)) > -1; }); cb(matches); }; @@ -21,7 +27,6 @@ var substringMatcher = function() { $.fn.bindSearch = function() { $(this).typeahead({ hint : true, - highlight : true, minLength : 1 }, { name : 'series', @@ -34,4 +39,4 @@ $.fn.bindSearch = function() { $(this).val(''); Backbone.history.navigate('/series/{0}'.format(series.titleSlug), { trigger : true }); }); -}; \ No newline at end of file +}; diff --git a/src/UI/Release/PeersCell.js b/src/UI/Release/PeersCell.js index 033c69115..4b9e6735d 100644 --- a/src/UI/Release/PeersCell.js +++ b/src/UI/Release/PeersCell.js @@ -5,8 +5,8 @@ module.exports = Backgrid.Cell.extend({ render : function() { if (this.model.get('protocol') === 'torrent') { - var seeders = this.model.get('seeders') || 0; - var leechers = this.model.get('leechers') || 0; + var seeders = this.model.get('seeders'); + var leechers = this.model.get('leechers'); var level = 'danger'; @@ -18,7 +18,19 @@ module.exports = Backgrid.Cell.extend({ level = 'primary'; } - this.$el.html('<div class="label label-{2}" title="{0} seeders, {1} leechers">{0} / {1}</div>'.format(seeders, leechers, level)); + var txtSeeders = seeders; + var txtLeechers = leechers; + if (typeof seeders === 'undefined') { + txtSeeders = 'unknown'; + seeders = '-'; + } + + if (typeof leechers === 'undefined') { + txtLeechers = 'unknown'; + leechers = '-'; + } + + this.$el.html('<div class="label label-{0}" title="{1} seeders, {2} leechers">{3} / {4}</div>'.format(level, txtSeeders, txtLeechers, seeders, leechers)); } this.delegateEvents(); diff --git a/src/UI/Series/Details/InfoViewTemplate.hbs b/src/UI/Series/Details/InfoViewTemplate.hbs index b52130246..1f21cc0de 100644 --- a/src/UI/Series/Details/InfoViewTemplate.hbs +++ b/src/UI/Series/Details/InfoViewTemplate.hbs @@ -37,10 +37,6 @@ <a href="{{imdbUrl}}" class="label label-info">IMDB</a> {{/if}} - {{#if tvRageId}} - <a href="{{tvRageUrl}}" class="label label-info">TV Rage</a> - {{/if}} - {{#if tvMazeId}} <a href="{{tvMazeUrl}}" class="label label-info">TV Maze</a> {{/if}} diff --git a/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryViewTemplate.hbs b/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryViewTemplate.hbs index 9043ad2f5..b7c39170c 100644 --- a/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryViewTemplate.hbs +++ b/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryViewTemplate.hbs @@ -1,5 +1,8 @@ <fieldset class="advanced-setting"> <legend>Drone Factory Options</legend> + <div class="alert alert-warning"> + Drone Factory is deprecated and should be disabled, use Wanted->Manual Import to manually import arbitrary directories. See <a href="https://github.com/Sonarr/Sonarr/wiki/Health-Checks#drone-factory-is-deprecated">Wiki</a>. + </div> <div class="form-group"> <label class="col-sm-3 control-label">Drone Factory</label> diff --git a/src/UI/Settings/Indexers/Options/IndexerOptionsViewTemplate.hbs b/src/UI/Settings/Indexers/Options/IndexerOptionsViewTemplate.hbs index 056d12648..374945620 100644 --- a/src/UI/Settings/Indexers/Options/IndexerOptionsViewTemplate.hbs +++ b/src/UI/Settings/Indexers/Options/IndexerOptionsViewTemplate.hbs @@ -31,6 +31,7 @@ <div class="col-sm-1 col-sm-push-2 help-inline"> <i class="icon-sonarr-form-warning" title="This will apply to all indexers, please follow the rules set forth by them"/> <i class="icon-sonarr-form-info" title="Interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)"/> + <a href="https://github.com/Sonarr/Sonarr/wiki/RSS-Sync" class="help-link" title="More information"><i class="icon-sonarr-form-info-link"/></a> </div> <div class="col-sm-2 col-sm-pull-1"> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/SeriesTitleNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/SeriesTitleNamingPartial.hbs index cc76c95b5..938055535 100644 --- a/src/UI/Settings/MediaManagement/Naming/Partials/SeriesTitleNamingPartial.hbs +++ b/src/UI/Settings/MediaManagement/Naming/Partials/SeriesTitleNamingPartial.hbs @@ -4,6 +4,7 @@ <li><a href="#" data-token="Series Title">Series Title</a></li> <li><a href="#" data-token="Series.Title">Series.Title</a></li> <li><a href="#" data-token="Series_Title">Series_Title</a></li> + <li><a href="#" data-token="Series TitleThe">Series Title, The</a></li> <li><a href="#" data-token="Series CleanTitle">Series CleanTitle</a></li> <li><a href="#" data-token="Series.CleanTitle">Series.CleanTitle</a></li> <li><a href="#" data-token="Series_CleanTitle">Series_CleanTitle</a></li> diff --git a/src/UI/Settings/Notifications/Edit/NotificationEditView.js b/src/UI/Settings/Notifications/Edit/NotificationEditView.js index 5e626ce90..3089b117b 100644 --- a/src/UI/Settings/Notifications/Edit/NotificationEditView.js +++ b/src/UI/Settings/Notifications/Edit/NotificationEditView.js @@ -91,8 +91,9 @@ var view = Marionette.ItemView.extend({ this.ui.indicator.show(); var self = this; + var callbackUrl = window.location.origin + window.NzbDrone.UrlBase + '/oauth.html'; - var promise = this.model.requestAction('startOAuth', { callbackUrl: window.location.origin + '/oauth.html' }) + var promise = this.model.requestAction('startOAuth', { callbackUrl: callbackUrl }) .then(function(response) { return self._showOAuthWindow(response.oauthUrl); }) @@ -137,4 +138,4 @@ AsModelBoundView.call(view); AsValidatedView.call(view); AsEditModalView.call(view); -module.exports = view; \ No newline at end of file +module.exports = view; diff --git a/src/UI/Shared/SignalRBroadcaster.js b/src/UI/Shared/SignalRBroadcaster.js index 2d5292760..204f77ab5 100644 --- a/src/UI/Shared/SignalRBroadcaster.js +++ b/src/UI/Shared/SignalRBroadcaster.js @@ -26,7 +26,7 @@ module.exports = { var tryingToReconnect = false; var messengerId = 'signalR'; - this.signalRconnection = $.connection(StatusModel.get('urlBase') + '/signalr'); + this.signalRconnection = $.connection(StatusModel.get('urlBase') + '/signalr', { apiKey: window.NzbDrone.ApiKey }); this.signalRconnection.stateChanged(function(change) { console.debug('SignalR: [{0}]'.format(getStatus(change.newState))); @@ -73,4 +73,4 @@ module.exports = { return this; } -}; \ No newline at end of file +}; diff --git a/src/UI/app.js b/src/UI/app.js index 3ebfafdb0..0fe17f06e 100644 --- a/src/UI/app.js +++ b/src/UI/app.js @@ -30,6 +30,7 @@ require.config({ 'jquery' : 'JsLibraries/jquery', 'typeahead' : 'JsLibraries/typeahead', 'zero.clipboard' : 'JsLibraries/zero.clipboard', + 'jdu' : 'JsLibraries/jdu', 'libs' : 'JsLibraries/' }, @@ -156,4 +157,4 @@ require.config({ exports : 'Backgrid.Extension.SelectRowCell' } } -}); \ No newline at end of file +}); diff --git a/src/UI/index.html b/src/UI/index.html index 94ebba2af..40303b171 100644 --- a/src/UI/index.html +++ b/src/UI/index.html @@ -2,7 +2,6 @@ <html> <head> <title>Sonarr - @@ -16,8 +15,8 @@ - - + + diff --git a/src/UI/jQuery/jquery.validation.js b/src/UI/jQuery/jquery.validation.js index 18cdd2f51..f5b141008 100644 --- a/src/UI/jQuery/jquery.validation.js +++ b/src/UI/jQuery/jquery.validation.js @@ -92,9 +92,9 @@ module.exports = function() { if (error.infoLink) { if (error.detailedDescription) { - errorMessage += ' '; + errorMessage += ' '; } else { - errorMessage += ' '; + errorMessage += ' '; } } else if (error.detailedDescription) { errorMessage += ' '; diff --git a/src/UI/login.html b/src/UI/login.html index 487e62680..fd4f76839 100644 --- a/src/UI/login.html +++ b/src/UI/login.html @@ -2,7 +2,6 @@ Sonarr - Login - diff --git a/webpack.config.js b/webpack.config.js index 5a15477c9..0239dd789 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -49,6 +49,7 @@ module.exports = { 'typeahead': 'JsLibraries/typeahead', 'zero.clipboard': 'JsLibraries/zero.clipboard', 'bootstrap.tagsinput': 'JsLibraries/bootstrap.tagsinput', + 'jdu': 'JsLibraries/jdu', 'libs': 'JsLibraries/' } },