diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 6bd416a38..a6c3f5fd1 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,5 +1,41 @@ + +## Support / Questions +Please use https://forums.sonarr.tv/ for support. Support requests or questions will be redirected to the forums and the issue will be closed. -Provide a description of the feature request or bug, the more details the better. -Please use https://forums.sonarr.tv/ for support or other questions. (When in doubt, use the forums) + + +## Bug Report + +### System Information/Logs + +**Sonarr Version:** + +**Operating System:** + +**.net Framework (Windows) or mono (macOS/Linux) Version:** + +**Link to Log Files (debug or trace):** + +**Browser (for UI bugs):** + +### Additional Information + + + +## Feature Request + +### What problem are you looking to solve? + +### Other Information diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..da40b7d70 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Create a report to help us improve Sonarr + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Logs** +Link to debug or trace log files. + +**System Information** + + - Sonarr Version: [e.g. 2.0.0.1] + - Operating System: [e.g. Windows 10] + - .net Framework (Windows) or mono (macOS/Linux) Version: [e.g. 4.5 or 5.12] + +**UI Bugs:** + - OS: [e.g. Windows] + - Browser: [e.g. chrome, firefox] + - Version: [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..eb428e271 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest an idea for Sonarr + +--- + +**Describe the problem** +A clear and concise description of the problem you're looking to solve. + +**Describe any solutions you think might work** +A clear and concise description of any solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/other-issues.md b/.github/ISSUE_TEMPLATE/other-issues.md new file mode 100644 index 000000000..16c3ba22c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/other-issues.md @@ -0,0 +1,7 @@ +--- +name: Other issues +about: How to get support or ask questions + +--- + +Please use https://forums.sonarr.tv/ for support. Support requests or questions will be redirected to the forums and the issue will be closed. diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 000000000..c6cf56c79 --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,7 @@ +## Support + +There are a number of frequently asked questions that have been answered in our [FAQ](https://github.com/Sonarr/Sonarr/wiki/FAQ) + +The [wiki](https://github.com/Sonarr/Sonarr/wiki) contains other information and guides + +If you have a support question, please use the [support forums](https://forums.sonarr.tv/). diff --git a/.gitignore b/.gitignore index 8762d35b3..bd5b618c3 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,7 @@ output/* #OS X metadata files ._* +.DS_Store _start _temp_*/**/* 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/setup/build.bat b/setup/build.bat index 1821e5844..964fe8abf 100644 --- a/setup/build.bat +++ b/setup/build.bat @@ -1,3 +1,3 @@ -#SET BUILD_NUMBER=1 -#SET branch=develop +REM SET BUILD_NUMBER=1 +REM SET branch=develop inno\ISCC.exe nzbdrone.iss \ No newline at end of file diff --git a/setup/nzbdrone.iss b/setup/nzbdrone.iss index e667c0d03..3e471e100 100644 --- a/setup/nzbdrone.iss +++ b/setup/nzbdrone.iss @@ -40,8 +40,11 @@ VersionInfoVersion={#BuildNumber} Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] -;Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked -Name: "windowsService"; Description: "Install as a Windows Service" +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}" +Name: "windowsService"; Description: "Install Windows Service (Starts when the computer starts)"; GroupDescription: "Start automatically"; Flags: exclusive +Name: "startupShortcut"; Description: "Create shortcut in Startup folder (Starts when you log into Windows)"; GroupDescription: "Start automatically"; Flags: exclusive unchecked +Name: "none"; Description: "Do not start automatically"; GroupDescription: "Start automatically"; Flags: exclusive unchecked + [Files] Source: "..\_output\NzbDrone.exe"; DestDir: "{app}"; Flags: ignoreversion @@ -51,10 +54,13 @@ Source: "..\_output\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs cr [Icons] Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon" Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon" +Name: "{userstartup}\{#AppName}"; Filename: "{app}\NzbDrone.exe"; WorkingDir: "{app}"; Tasks: startupShortcut [Run] -Filename: "{app}\nzbdrone.console.exe"; Parameters: "/u"; Flags: waituntilterminated; -Filename: "{app}\nzbdrone.console.exe"; Parameters: "/i"; Flags: waituntilterminated; Tasks: windowsService +Filename: "{app}\nzbdrone.console.exe"; Parameters: "/u"; Flags: runhidden waituntilterminated; +Filename: "{app}\nzbdrone.console.exe"; Parameters: "/i"; Flags: runhidden waituntilterminated; Tasks: windowsService +Filename: "{app}\NzbDrone.exe"; Description: "Open Sonarr"; Flags: postinstall skipifsilent nowait; Tasks: windowsService; +Filename: "{app}\NzbDrone.exe"; Description: "Start Sonarr"; Flags: postinstall skipifsilent nowait; Tasks: startupShortcut none; [UninstallRun] Filename: "{app}\nzbdrone.console.exe"; Parameters: "/u"; Flags: waituntilterminated skipifdoesntexist diff --git a/src/Libraries/MediaInfo/MediaInfo.dll b/src/Libraries/MediaInfo/MediaInfo.dll index 6a0b4f40f..24e6cb986 100644 Binary files a/src/Libraries/MediaInfo/MediaInfo.dll and b/src/Libraries/MediaInfo/MediaInfo.dll differ diff --git a/src/Libraries/MediaInfo/libmediainfo.0.dylib b/src/Libraries/MediaInfo/libmediainfo.0.dylib index 9aac5caea..5e5383ded 100644 Binary files a/src/Libraries/MediaInfo/libmediainfo.0.dylib and b/src/Libraries/MediaInfo/libmediainfo.0.dylib differ diff --git a/src/LogentriesNLog/LogentriesNLog.csproj b/src/LogentriesNLog/LogentriesNLog.csproj index 54bf715e7..fba6880c2 100644 --- a/src/LogentriesNLog/LogentriesNLog.csproj +++ b/src/LogentriesNLog/LogentriesNLog.csproj @@ -52,10 +52,14 @@ - ..\packages\NLog.4.4.3\lib\net40\NLog.dll + ..\packages\NLog.4.5.3\lib\net40-client\NLog.dll + + + + diff --git a/src/LogentriesNLog/packages.config b/src/LogentriesNLog/packages.config index a14101dce..6f6ef792a 100644 --- a/src/LogentriesNLog/packages.config +++ b/src/LogentriesNLog/packages.config @@ -1,4 +1,4 @@  - + \ No newline at end of file diff --git a/src/Marr.Data/Reflection/SimpleReflectionStrategy.cs b/src/Marr.Data/Reflection/SimpleReflectionStrategy.cs index 9e741b013..25b25de5b 100644 --- a/src/Marr.Data/Reflection/SimpleReflectionStrategy.cs +++ b/src/Marr.Data/Reflection/SimpleReflectionStrategy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq.Expressions; using System.Reflection; diff --git a/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs b/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs index 385a9b989..5a767365d 100644 --- a/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs +++ b/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs @@ -21,19 +21,32 @@ namespace NzbDrone.Api.Test.ClientSchemaTests public void schema_should_have_proper_fields() { var model = new TestModel - { - FirstName = "Bob", - LastName = "Poop" - }; + { + FirstName = "Bob", + LastName = "Poop" + }; var schema = SchemaBuilder.ToSchema(model); - schema.Should().Contain(c => c.Order == 1 && c.Name == "LastName" && c.Label == "Last Name" && c.HelpText == "Your Last Name" && (string) c.Value == "Poop"); - schema.Should().Contain(c => c.Order == 0 && c.Name == "FirstName" && c.Label == "First Name" && c.HelpText == "Your First Name" && (string) c.Value == "Bob"); + schema.Should().Contain(c => c.Order == 1 && c.Name == "LastName" && c.Label == "Last Name" && c.HelpText == "Your Last Name" && (string)c.Value == "Poop"); + schema.Should().Contain(c => c.Order == 0 && c.Name == "FirstName" && c.Label == "First Name" && c.HelpText == "Your First Name" && (string)c.Value == "Bob"); } - } + [Test] + public void schema_should_have_nested_fields() + { + var model = new NestedTestModel(); + model.Name.FirstName = "Bob"; + model.Name.LastName = "Poop"; + + var schema = SchemaBuilder.ToSchema(model); + + schema.Should().Contain(c => c.Order == 0 && c.Name == "Name.FirstName" && c.Label == "First Name" && c.HelpText == "Your First Name" && (string)c.Value == "Bob"); + schema.Should().Contain(c => c.Order == 1 && c.Name == "Name.LastName" && c.Label == "Last Name" && c.HelpText == "Your Last Name" && (string)c.Value == "Poop"); + schema.Should().Contain(c => c.Order == 2 && c.Name == "Quote" && c.Label == "Quote" && c.HelpText == "Your Favorite Quote"); + } + } public class TestModel { @@ -45,4 +58,13 @@ namespace NzbDrone.Api.Test.ClientSchemaTests public string Other { get; set; } } -} \ No newline at end of file + + public class NestedTestModel + { + [FieldDefinition(0)] + public TestModel Name { get; set; } = new TestModel(); + + [FieldDefinition(1, Label = "Quote", HelpText = "Your Favorite Quote")] + public string Quote { get; set; } + } +} diff --git a/src/NzbDrone.Api.Test/NzbDrone.Api.Test.csproj b/src/NzbDrone.Api.Test/NzbDrone.Api.Test.csproj index 69402ccc5..9d787542e 100644 --- a/src/NzbDrone.Api.Test/NzbDrone.Api.Test.csproj +++ b/src/NzbDrone.Api.Test/NzbDrone.Api.Test.csproj @@ -48,8 +48,8 @@ ..\packages\FluentAssertions.4.19.0\lib\net40\FluentAssertions.Core.dll - - ..\packages\Moq.4.2.1510.2205\lib\net40\Moq.dll + + True ..\packages\NUnit.3.6.0\lib\net40\nunit.framework.dll diff --git a/src/NzbDrone.Api.Test/packages.config b/src/NzbDrone.Api.Test/packages.config index b329faeb1..0913b6416 100644 --- a/src/NzbDrone.Api.Test/packages.config +++ b/src/NzbDrone.Api.Test/packages.config @@ -1,7 +1,7 @@  - + \ No newline at end of file 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/Calendar/CalendarFeedModule.cs b/src/NzbDrone.Api/Calendar/CalendarFeedModule.cs index 4845bc653..aec7320e7 100644 --- a/src/NzbDrone.Api/Calendar/CalendarFeedModule.cs +++ b/src/NzbDrone.Api/Calendar/CalendarFeedModule.cs @@ -1,9 +1,10 @@ -using Nancy; +using Nancy; using System; using System.Collections.Generic; using System.Linq; using Ical.Net; using Ical.Net.DataTypes; +using Ical.Net.General; using Ical.Net.Interfaces.Serialization; using Ical.Net.Serialization; using Ical.Net.Serialization.iCalendar.Factory; @@ -92,7 +93,9 @@ namespace NzbDrone.Api.Calendar ProductId = "-//sonarr.tv//Sonarr//EN" }; - + var calendarName = "Sonarr TV Schedule"; + calendar.AddProperty(new CalendarProperty("NAME", calendarName)); + calendar.AddProperty(new CalendarProperty("X-WR-CALNAME", calendarName)); foreach (var episode in episodes.OrderBy(v => v.AirDateUtc.Value)) { @@ -114,7 +117,7 @@ namespace NzbDrone.Api.Calendar if (asAllDay) { - occurrence.Start = new CalDateTime(episode.AirDateUtc.Value) { HasTime = false }; + occurrence.Start = new CalDateTime(episode.AirDateUtc.Value.ToLocalTime()) { HasTime = false }; } else { diff --git a/src/NzbDrone.Api/ClientSchema/Field.cs b/src/NzbDrone.Api/ClientSchema/Field.cs index ec611e8d6..ff9f6aebd 100644 --- a/src/NzbDrone.Api/ClientSchema/Field.cs +++ b/src/NzbDrone.Api/ClientSchema/Field.cs @@ -7,11 +7,17 @@ namespace NzbDrone.Api.ClientSchema public int Order { get; set; } public string Name { get; set; } public string Label { get; set; } + public string Unit { get; set; } public string HelpText { get; set; } public string HelpLink { get; set; } public object Value { get; set; } public string Type { get; set; } public bool Advanced { get; set; } public List SelectOptions { get; set; } + + public Field Clone() + { + return (Field)MemberwiseClone(); + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/ClientSchema/FieldMapping.cs b/src/NzbDrone.Api/ClientSchema/FieldMapping.cs new file mode 100644 index 000000000..93e90b792 --- /dev/null +++ b/src/NzbDrone.Api/ClientSchema/FieldMapping.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Api.ClientSchema +{ + public class FieldMapping + { + public Field Field { get; set; } + public Type PropertyType { get; set; } + public Func GetterFunc { get; set; } + public Action SetterFunc { get; set; } + } +} diff --git a/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs b/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs index 0a7acb9e1..c2e4a4379 100644 --- a/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs +++ b/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using Newtonsoft.Json.Linq; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; @@ -11,45 +12,22 @@ namespace NzbDrone.Api.ClientSchema { public static class SchemaBuilder { + private static Dictionary _mappings = new Dictionary(); + public static List ToSchema(object model) { Ensure.That(model, () => model).IsNotNull(); - var properties = model.GetType().GetSimpleProperties(); + var mappings = GetFieldMappings(model.GetType()); - var result = new List(properties.Count); + var result = new List(mappings.Length); - foreach (var propertyInfo in properties) + foreach (var mapping in mappings) { - var fieldAttribute = propertyInfo.GetAttribute(false); + var field = mapping.Field.Clone(); + field.Value = mapping.GetterFunc(model); - if (fieldAttribute != null) - { - - var field = new Field - { - Name = propertyInfo.Name, - Label = fieldAttribute.Label, - HelpText = fieldAttribute.HelpText, - HelpLink = fieldAttribute.HelpLink, - Order = fieldAttribute.Order, - Advanced = fieldAttribute.Advanced, - Type = fieldAttribute.Type.ToString().ToLowerInvariant() - }; - - var value = propertyInfo.GetValue(model, null); - if (value != null) - { - field.Value = value; - } - - if (fieldAttribute.Type == FieldType.Select) - { - field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions); - } - - result.Add(field); - } + result.Add(field); } return result.OrderBy(r => r.Order).ToList(); @@ -59,81 +37,16 @@ namespace NzbDrone.Api.ClientSchema { Ensure.That(targetType, () => targetType).IsNotNull(); - var properties = targetType.GetSimpleProperties(); + var mappings = GetFieldMappings(targetType); var target = Activator.CreateInstance(targetType); - foreach (var propertyInfo in properties) + foreach (var mapping in mappings) { - var fieldAttribute = propertyInfo.GetAttribute(false); + var propertyType = mapping.PropertyType; + var field = fields.Find(f => f.Name == mapping.Field.Name); - if (fieldAttribute != null) - { - var field = fields.Find(f => f.Name == propertyInfo.Name); - - if (propertyInfo.PropertyType == typeof(int)) - { - var value = field.Value.ToString().ParseInt32(); - propertyInfo.SetValue(target, value ?? 0, null); - } - - else if (propertyInfo.PropertyType == typeof(long)) - { - var value = field.Value.ToString().ParseInt64(); - propertyInfo.SetValue(target, value ?? 0, null); - } - - else if (propertyInfo.PropertyType == typeof(int?)) - { - var value = field.Value.ToString().ParseInt32(); - propertyInfo.SetValue(target, value, null); - } - - else if (propertyInfo.PropertyType == typeof(Nullable)) - { - var value = field.Value.ToString().ParseInt64(); - propertyInfo.SetValue(target, value, null); - } - - else if (propertyInfo.PropertyType == typeof(IEnumerable)) - { - IEnumerable value; - - if (field.Value.GetType() == typeof(JArray)) - { - value = ((JArray)field.Value).Select(s => s.Value()); - } - - else - { - value = field.Value.ToString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => Convert.ToInt32(s)); - } - - propertyInfo.SetValue(target, value, null); - } - - else if (propertyInfo.PropertyType == typeof(IEnumerable)) - { - IEnumerable value; - - if (field.Value.GetType() == typeof(JArray)) - { - value = ((JArray)field.Value).Select(s => s.Value()); - } - - else - { - value = field.Value.ToString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - propertyInfo.SetValue(target, value, null); - } - - else - { - propertyInfo.SetValue(target, field.Value, null); - } - } + mapping.SetterFunc(target, field.Value); } return target; @@ -145,6 +58,84 @@ namespace NzbDrone.Api.ClientSchema return (T)ReadFromSchema(fields, typeof(T)); } + + // Ideally this function should begin a System.Linq.Expression expression tree since it's faster. + // But it's probably not needed till performance issues pop up. + public static FieldMapping[] GetFieldMappings(Type type) + { + lock (_mappings) + { + FieldMapping[] result; + if (!_mappings.TryGetValue(type, out result)) + { + result = GetFieldMapping(type, "", v => v); + + // Renumber al the field Orders since nested settings will have dupe Orders. + for (int i = 0; i < result.Length; i++) + { + result[i].Field.Order = i; + } + + _mappings[type] = result; + } + return result; + } + } + + private static FieldMapping[] GetFieldMapping(Type type, string prefix, Func targetSelector) + { + var result = new List(); + foreach (var property in GetProperties(type)) + { + var propertyInfo = property.Item1; + if (propertyInfo.PropertyType.IsSimpleType()) + { + var fieldAttribute = property.Item2; + var field = new Field + { + Name = prefix + propertyInfo.Name, + Label = fieldAttribute.Label, + Unit = fieldAttribute.Unit, + HelpText = fieldAttribute.HelpText, + HelpLink = fieldAttribute.HelpLink, + Order = fieldAttribute.Order, + Advanced = fieldAttribute.Advanced, + Type = fieldAttribute.Type.ToString().ToLowerInvariant() + }; + + if (fieldAttribute.Type == FieldType.Select) + { + field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions); + } + + var valueConverter = GetValueConverter(propertyInfo.PropertyType); + + result.Add(new FieldMapping + { + Field = field, + PropertyType = propertyInfo.PropertyType, + GetterFunc = t => propertyInfo.GetValue(targetSelector(t), null), + SetterFunc = (t, v) => propertyInfo.SetValue(targetSelector(t), valueConverter(v), null) + }); + } + else + { + result.AddRange(GetFieldMapping(propertyInfo.PropertyType, propertyInfo.Name + ".", t => propertyInfo.GetValue(targetSelector(t), null))); + } + } + + return result.ToArray(); + } + + private static Tuple[] GetProperties(Type type) + { + return type.GetProperties() + .Select(v => Tuple.Create(v, v.GetAttribute(false))) + .Where(v => v.Item2 != null) + .OrderBy(v => v.Item2.Order) + .ToArray(); + } + private static List GetSelectOptions(Type selectOptions) { var options = from Enum e in Enum.GetValues(selectOptions) @@ -152,5 +143,73 @@ namespace NzbDrone.Api.ClientSchema return options.OrderBy(o => o.Value).ToList(); } + + private static Func GetValueConverter(Type propertyType) + { + if (propertyType == typeof(int)) + { + return fieldValue => fieldValue?.ToString().ParseInt32() ?? 0; + } + + else if (propertyType == typeof(long)) + { + return fieldValue => fieldValue?.ToString().ParseInt64() ?? 0; + } + + else if (propertyType == typeof(double)) + { + return fieldValue => fieldValue?.ToString().ParseDouble() ?? 0.0; + } + + else if (propertyType == typeof(int?)) + { + return fieldValue => fieldValue?.ToString().ParseInt32(); + } + + else if (propertyType == typeof(Int64?)) + { + return fieldValue => fieldValue?.ToString().ParseInt64(); + } + + else if (propertyType == typeof(double?)) + { + return fieldValue => fieldValue?.ToString().ParseDouble(); + } + + else if (propertyType == typeof(IEnumerable)) + { + return fieldValue => + { + if (fieldValue.GetType() == typeof(JArray)) + { + return ((JArray)fieldValue).Select(s => s.Value()); + } + else + { + return fieldValue.ToString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => Convert.ToInt32(s)); + } + }; + } + + else if (propertyType == typeof(IEnumerable)) + { + return fieldValue => + { + if (fieldValue.GetType() == typeof(JArray)) + { + return ((JArray)fieldValue).Select(s => s.Value()); + } + else + { + return fieldValue.ToString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + } + }; + } + + else + { + return fieldValue => fieldValue; + } + } } } diff --git a/src/NzbDrone.Api/Commands/CommandModule.cs b/src/NzbDrone.Api/Commands/CommandModule.cs index fcaeef9c4..0d085eb3e 100644 --- a/src/NzbDrone.Api/Commands/CommandModule.cs +++ b/src/NzbDrone.Api/Commands/CommandModule.cs @@ -4,6 +4,7 @@ using System.Linq; using NzbDrone.Api.Extensions; using NzbDrone.Api.Validation; using NzbDrone.Common; +using NzbDrone.Common.TPL; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; @@ -17,6 +18,8 @@ namespace NzbDrone.Api.Commands { private readonly IManageCommandQueue _commandQueueManager; private readonly IServiceFactory _serviceFactory; + private readonly Debouncer _debouncer; + private readonly Dictionary _pendingUpdates; public CommandModule(IManageCommandQueue commandQueueManager, IBroadcastSignalRMessage signalRBroadcaster, @@ -31,6 +34,10 @@ namespace NzbDrone.Api.Commands GetResourceAll = GetStartedCommands; PostValidator.RuleFor(c => c.Name).NotBlank(); + + _debouncer = new Debouncer(SendUpdates, TimeSpan.FromSeconds(0.1)); + _pendingUpdates = new Dictionary(); + } private CommandResource GetCommand(int id) @@ -59,8 +66,26 @@ namespace NzbDrone.Api.Commands { if (message.Command.Body.SendUpdatesToClient) { - BroadcastResourceChange(ModelAction.Updated, message.Command.ToResource()); + lock (_pendingUpdates) + { + _pendingUpdates[message.Command.Id] = message.Command.ToResource(); + } + _debouncer.Execute(); + } + } + + private void SendUpdates() + { + lock (_pendingUpdates) + { + var pendingUpdates = _pendingUpdates.Values.ToArray(); + _pendingUpdates.Clear(); + + foreach (var pendingUpdate in pendingUpdates) + { + BroadcastResourceChange(ModelAction.Updated, pendingUpdate); + } } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Config/IndexerConfigModule.cs b/src/NzbDrone.Api/Config/IndexerConfigModule.cs index 73c2442b8..ebb8f7cd8 100644 --- a/src/NzbDrone.Api/Config/IndexerConfigModule.cs +++ b/src/NzbDrone.Api/Config/IndexerConfigModule.cs @@ -13,6 +13,9 @@ namespace NzbDrone.Api.Config SharedValidator.RuleFor(c => c.MinimumAge) .GreaterThanOrEqualTo(0); + SharedValidator.RuleFor(c => c.MaximumSize) + .GreaterThanOrEqualTo(0); + SharedValidator.RuleFor(c => c.Retention) .GreaterThanOrEqualTo(0); @@ -25,4 +28,4 @@ namespace NzbDrone.Api.Config return IndexerConfigResourceMapper.ToResource(model); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Config/IndexerConfigResource.cs b/src/NzbDrone.Api/Config/IndexerConfigResource.cs index 179e28c3f..59fcb48e3 100644 --- a/src/NzbDrone.Api/Config/IndexerConfigResource.cs +++ b/src/NzbDrone.Api/Config/IndexerConfigResource.cs @@ -6,6 +6,7 @@ namespace NzbDrone.Api.Config public class IndexerConfigResource : RestResource { public int MinimumAge { get; set; } + public int MaximumSize { get; set; } public int Retention { get; set; } public int RssSyncInterval { get; set; } } @@ -17,6 +18,7 @@ namespace NzbDrone.Api.Config return new IndexerConfigResource { MinimumAge = model.MinimumAge, + MaximumSize = model.MaximumSize, Retention = model.Retention, RssSyncInterval = model.RssSyncInterval, }; diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs index fb6ef94e5..b10d8209f 100644 --- a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs +++ b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs @@ -1,4 +1,4 @@ -using NzbDrone.Api.REST; +using NzbDrone.Api.REST; using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles; @@ -10,6 +10,7 @@ namespace NzbDrone.Api.Config public string RecycleBin { get; set; } public bool AutoDownloadPropers { get; set; } public bool CreateEmptySeriesFolders { get; set; } + public bool DeleteEmptyFolders { get; set; } public FileDateType FileDate { get; set; } public bool SetPermissionsLinux { get; set; } @@ -35,6 +36,7 @@ namespace NzbDrone.Api.Config RecycleBin = model.RecycleBin, AutoDownloadPropers = model.AutoDownloadPropers, CreateEmptySeriesFolders = model.CreateEmptySeriesFolders, + DeleteEmptyFolders = model.DeleteEmptyFolders, FileDate = model.FileDate, SetPermissionsLinux = model.SetPermissionsLinux, 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/EpisodeFiles/EpisodeFileResource.cs b/src/NzbDrone.Api/EpisodeFiles/EpisodeFileResource.cs index bd856776d..55e01c41f 100644 --- a/src/NzbDrone.Api/EpisodeFiles/EpisodeFileResource.cs +++ b/src/NzbDrone.Api/EpisodeFiles/EpisodeFileResource.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using NzbDrone.Api.REST; using NzbDrone.Core.Qualities; @@ -15,6 +15,7 @@ namespace NzbDrone.Api.EpisodeFiles public DateTime DateAdded { get; set; } public string SceneName { get; set; } public QualityModel Quality { get; set; } + public MediaInfoResource MediaInfo { get; set; } public bool QualityCutoffNotMet { get; set; } } @@ -37,6 +38,7 @@ namespace NzbDrone.Api.EpisodeFiles DateAdded = model.DateAdded, SceneName = model.SceneName, Quality = model.Quality, + MediaInfo = model.MediaInfo.ToResource(model.SceneName) //QualityCutoffNotMet }; } @@ -57,7 +59,8 @@ namespace NzbDrone.Api.EpisodeFiles DateAdded = model.DateAdded, SceneName = model.SceneName, Quality = model.Quality, - QualityCutoffNotMet = qualityUpgradableSpecification.CutoffNotMet(series.Profile.Value, model.Quality) + QualityCutoffNotMet = qualityUpgradableSpecification.CutoffNotMet(series.Profile.Value, model.Quality), + MediaInfo = model.MediaInfo.ToResource(model.SceneName), }; } } diff --git a/src/NzbDrone.Api/EpisodeFiles/MediaInfoResource.cs b/src/NzbDrone.Api/EpisodeFiles/MediaInfoResource.cs new file mode 100644 index 000000000..672c48fcd --- /dev/null +++ b/src/NzbDrone.Api/EpisodeFiles/MediaInfoResource.cs @@ -0,0 +1,30 @@ +using NzbDrone.Api.REST; +using NzbDrone.Core.MediaFiles.MediaInfo; + +namespace NzbDrone.Api.EpisodeFiles +{ + public class MediaInfoResource : RestResource + { + public decimal AudioChannels { get; set; } + public string AudioCodec { get; set; } + public string VideoCodec { get; set; } + } + + public static class MediaInfoResourceMapper + { + public static MediaInfoResource ToResource(this MediaInfoModel model, string sceneName) + { + if (model == null) + { + return null; + } + + return new MediaInfoResource + { + AudioChannels = MediaInfoFormatter.FormatAudioChannels(model), + AudioCodec = MediaInfoFormatter.FormatAudioCodec(model, sceneName), + VideoCodec = MediaInfoFormatter.FormatVideoCodec(model, sceneName) + }; + } + } +} 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/CacheHeaderPipeline.cs b/src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs index 94c738d9b..d8e9266ad 100644 --- a/src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs +++ b/src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs @@ -23,6 +23,8 @@ namespace NzbDrone.Api.Extensions.Pipelines private void Handle(NancyContext context) { + if (context.Request.Method == "OPTIONS") return; + if (_cacheableSpecification.IsCacheable(context)) { context.Response.Headers.EnableCache(); @@ -33,4 +35,4 @@ namespace NzbDrone.Api.Extensions.Pipelines } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Extensions/Pipelines/CorsPipeline.cs b/src/NzbDrone.Api/Extensions/Pipelines/CorsPipeline.cs index b8c83298a..ad98837e8 100644 --- a/src/NzbDrone.Api/Extensions/Pipelines/CorsPipeline.cs +++ b/src/NzbDrone.Api/Extensions/Pipelines/CorsPipeline.cs @@ -2,6 +2,7 @@ using System.Linq; using Nancy; using Nancy.Bootstrapper; +using NzbDrone.Common.Extensions; namespace NzbDrone.Api.Extensions.Pipelines { @@ -11,10 +12,25 @@ namespace NzbDrone.Api.Extensions.Pipelines public void Register(IPipelines pipelines) { - pipelines.AfterRequest.AddItemToEndOfPipeline((Action) Handle); + pipelines.BeforeRequest.AddItemToEndOfPipeline(HandleRequest); + pipelines.AfterRequest.AddItemToEndOfPipeline(HandleResponse); } - private void Handle(NancyContext context) + private Response HandleRequest(NancyContext context) + { + if (context == null || context.Request.Method != "OPTIONS") + { + return null; + } + + var response = new Response() + .WithStatusCode(HttpStatusCode.OK) + .WithContentType(""); + ApplyResponseHeaders(response, context.Request); + return response; + } + + private void HandleResponse(NancyContext context) { if (context == null || context.Response.Headers.ContainsKey(AccessControlHeaders.AllowOrigin)) { @@ -26,21 +42,39 @@ namespace NzbDrone.Api.Extensions.Pipelines private static void ApplyResponseHeaders(Response response, Request request) { - var allowedMethods = "GET, OPTIONS, PATCH, POST, PUT, DELETE"; - - if (response.Headers.ContainsKey("Allow")) + if (request.IsApiRequest()) { - allowedMethods = response.Headers["Allow"]; + // Allow Cross-Origin access to the API since it's protected with the apikey, and nothing else. + ApplyCorsResponseHeaders(response, request, "*", "GET, OPTIONS, PATCH, POST, PUT, DELETE"); } - - var requestedHeaders = string.Join(", ", request.Headers[AccessControlHeaders.RequestHeaders]); - - response.Headers.Add(AccessControlHeaders.AllowOrigin, "*"); - response.Headers.Add(AccessControlHeaders.AllowMethods, allowedMethods); - - if (request.Headers[AccessControlHeaders.RequestHeaders].Any()) + else if (request.IsSharedContentRequest()) { - response.Headers.Add(AccessControlHeaders.AllowHeaders, requestedHeaders); + // Allow Cross-Origin access to specific shared content such as mediacovers and images. + ApplyCorsResponseHeaders(response, request, "*", "GET, OPTIONS"); + } + + // Disallow Cross-Origin access for any other route. + } + + private static void ApplyCorsResponseHeaders(Response response, Request request, string allowOrigin, string allowedMethods) + { + response.Headers.Add(AccessControlHeaders.AllowOrigin, allowOrigin); + + if (request.Method == "OPTIONS") + { + if (response.Headers.ContainsKey("Allow")) + { + allowedMethods = response.Headers["Allow"]; + } + + response.Headers.Add(AccessControlHeaders.AllowMethods, allowedMethods); + + if (request.Headers[AccessControlHeaders.RequestHeaders].Any()) + { + var requestedHeaders = request.Headers[AccessControlHeaders.RequestHeaders].Join(", "); + + response.Headers.Add(AccessControlHeaders.AllowHeaders, requestedHeaders); + } } } } diff --git a/src/NzbDrone.Api/Extensions/Pipelines/GZipPipeline.cs b/src/NzbDrone.Api/Extensions/Pipelines/GZipPipeline.cs index 12293f23c..8aa9f4ad2 100644 --- a/src/NzbDrone.Api/Extensions/Pipelines/GZipPipeline.cs +++ b/src/NzbDrone.Api/Extensions/Pipelines/GZipPipeline.cs @@ -33,7 +33,8 @@ namespace NzbDrone.Api.Extensions.Pipelines try { if ( - !response.ContentType.Contains("image") + response.Contents != Response.NoBody + && !response.ContentType.Contains("image") && !response.ContentType.Contains("font") && request.Headers.AcceptEncoding.Any(x => x.Contains("gzip")) && !AlreadyGzipEncoded(response) @@ -80,4 +81,4 @@ namespace NzbDrone.Api.Extensions.Pipelines return false; } } -} \ No newline at end of file +} 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/Extensions/RequestExtensions.cs b/src/NzbDrone.Api/Extensions/RequestExtensions.cs index 6c112c900..f08c0c075 100644 --- a/src/NzbDrone.Api/Extensions/RequestExtensions.cs +++ b/src/NzbDrone.Api/Extensions/RequestExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using Nancy; namespace NzbDrone.Api.Extensions @@ -36,5 +36,23 @@ namespace NzbDrone.Api.Extensions { return request.Path.StartsWith("/Content/", StringComparison.InvariantCultureIgnoreCase); } + + public static bool IsSharedContentRequest(this Request request) + { + return request.Path.StartsWith("/MediaCover/", StringComparison.InvariantCultureIgnoreCase) || + request.Path.StartsWith("/Content/Images/", StringComparison.InvariantCultureIgnoreCase); + } + + public static bool GetBooleanQueryParameter(this Request request, string parameter, bool defaultValue = false) + { + var parameterValue = request.Query[parameter]; + + if (parameterValue.HasValue) + { + return bool.Parse(parameterValue.Value); + } + + return defaultValue; + } } } diff --git a/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs index ae66b2aa2..218d185f5 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; @@ -49,7 +49,12 @@ namespace NzbDrone.Api.Frontend.Mappers public override bool CanHandle(string resourceUrl) { - return !resourceUrl.Contains(".") && !resourceUrl.StartsWith("/login"); + resourceUrl = resourceUrl.ToLowerInvariant(); + + return !resourceUrl.StartsWith("/content") && + !resourceUrl.StartsWith("/mediacover") && + !resourceUrl.Contains(".") && + !resourceUrl.StartsWith("/login"); } public override Response GetResponse(string resourceUrl) @@ -113,4 +118,4 @@ namespace NzbDrone.Api.Frontend.Mappers return _generatedContent; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Frontend/Mappers/MediaCoverMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/MediaCoverMapper.cs index a4e5fb8f2..8a5626cf2 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/MediaCoverMapper.cs +++ b/src/NzbDrone.Api/Frontend/Mappers/MediaCoverMapper.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Text.RegularExpressions; using NLog; @@ -42,7 +43,7 @@ namespace NzbDrone.Api.Frontend.Mappers public override bool CanHandle(string resourceUrl) { - return resourceUrl.StartsWith("/MediaCover"); + return resourceUrl.StartsWith("/MediaCover", StringComparison.InvariantCultureIgnoreCase); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapper.cs index 61ed14e9b..4b3b939a1 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapper.cs +++ b/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapper.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using NLog; using NzbDrone.Common.Disk; @@ -28,7 +29,9 @@ namespace NzbDrone.Api.Frontend.Mappers public override bool CanHandle(string resourceUrl) { - return resourceUrl.StartsWith("/Content") || + resourceUrl = resourceUrl.ToLowerInvariant(); + + return resourceUrl.StartsWith("/content") || resourceUrl.EndsWith(".js") || resourceUrl.EndsWith(".map") || resourceUrl.EndsWith(".css") || @@ -37,4 +40,4 @@ namespace NzbDrone.Api.Frontend.Mappers resourceUrl.EndsWith("oauth.html"); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapperBase.cs b/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapperBase.cs index 5d4b57d50..6f7088832 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapperBase.cs +++ b/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapperBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using NLog; using Nancy; @@ -21,10 +21,7 @@ namespace NzbDrone.Api.Frontend.Mappers _diskProvider = diskProvider; _logger = logger; - if (!RuntimeInfo.IsProduction) - { - _caseSensitive = StringComparison.OrdinalIgnoreCase; - } + _caseSensitive = RuntimeInfo.IsProduction ? DiskProviderBase.PathStringComparison : StringComparison.OrdinalIgnoreCase; } public abstract string Map(string resourceUrl); @@ -50,6 +47,5 @@ namespace NzbDrone.Api.Frontend.Mappers { return File.OpenRead(filePath); } - } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Frontend/StaticResourceModule.cs b/src/NzbDrone.Api/Frontend/StaticResourceModule.cs index 7ec5fe9d8..270f48387 100644 --- a/src/NzbDrone.Api/Frontend/StaticResourceModule.cs +++ b/src/NzbDrone.Api/Frontend/StaticResourceModule.cs @@ -1,7 +1,6 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; -using Nancy.Responses; using NLog; using Nancy; using NzbDrone.Api.Frontend.Mappers; @@ -38,20 +37,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/History/HistoryModule.cs b/src/NzbDrone.Api/History/HistoryModule.cs index d85cf74d8..4902c195a 100644 --- a/src/NzbDrone.Api/History/HistoryModule.cs +++ b/src/NzbDrone.Api/History/HistoryModule.cs @@ -1,7 +1,10 @@ -using System; +using System; +using System.Collections.Generic; +using System.Linq; using Nancy; using NzbDrone.Api.Episodes; using NzbDrone.Api.Extensions; +using NzbDrone.Api.REST; using NzbDrone.Api.Series; using NzbDrone.Core.Datastore; using NzbDrone.Core.DecisionEngine; @@ -25,6 +28,7 @@ namespace NzbDrone.Api.History _failedDownloadService = failedDownloadService; GetResourcePaged = GetHistory; + Get["/since"] = x => GetHistorySince(); Post["/failed"] = x => MarkAsFailed(); } @@ -64,6 +68,27 @@ namespace NzbDrone.Api.History return ApplyToPage(_historyService.Paged, pagingSpec, MapToResource); } + private List GetHistorySince() + { + var queryDate = Request.Query.Date; + var queryEventType = Request.Query.EventType; + + if (!queryDate.HasValue) + { + throw new BadRequestException("date is missing"); + } + + DateTime date = DateTime.Parse(queryDate.Value); + HistoryEventType? eventType = null; + + if (queryEventType.HasValue) + { + eventType = (HistoryEventType)Convert.ToInt32(queryEventType.Value); + } + + return _historyService.Since(date, eventType).Select(MapToResource).ToList(); + } + private Response MarkAsFailed() { var id = (int)Request.Form.Id; 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/ReleasePushModule.cs b/src/NzbDrone.Api/Indexers/ReleasePushModule.cs index c25e45726..7cb93739c 100644 --- a/src/NzbDrone.Api/Indexers/ReleasePushModule.cs +++ b/src/NzbDrone.Api/Indexers/ReleasePushModule.cs @@ -1,13 +1,16 @@ -using Nancy; -using Nancy.ModelBinding; +using System.Collections.Generic; +using System.Linq; using FluentValidation; +using Nancy; +using Nancy.ModelBinding; +using NLog; +using NzbDrone.Api.Extensions; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; -using System.Collections.Generic; -using System.Linq; +using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; -using NzbDrone.Api.Extensions; -using NLog; namespace NzbDrone.Api.Indexers { @@ -15,14 +18,17 @@ namespace NzbDrone.Api.Indexers { private readonly IMakeDownloadDecision _downloadDecisionMaker; private readonly IProcessDownloadDecisions _downloadDecisionProcessor; + private readonly IIndexerFactory _indexerFactory; private readonly Logger _logger; public ReleasePushModule(IMakeDownloadDecision downloadDecisionMaker, IProcessDownloadDecisions downloadDecisionProcessor, + IIndexerFactory indexerFactory, Logger logger) { _downloadDecisionMaker = downloadDecisionMaker; _downloadDecisionProcessor = downloadDecisionProcessor; + _indexerFactory = indexerFactory; _logger = logger; Post["/push"] = x => ProcessRelease(this.Bind()); @@ -41,10 +47,47 @@ namespace NzbDrone.Api.Indexers info.Guid = "PUSH-" + info.DownloadUrl; + ResolveIndexer(info); + var decisions = _downloadDecisionMaker.GetRssDecision(new List { info }); _downloadDecisionProcessor.ProcessDecisions(decisions); return MapDecisions(decisions).First().AsResponse(); } + + private void ResolveIndexer(ReleaseInfo release) + { + if (release.IndexerId == 0 && release.Indexer.IsNotNullOrWhiteSpace()) + { + var indexer = _indexerFactory.All().FirstOrDefault(v => v.Name == release.Indexer); + if (indexer != null) + { + release.IndexerId = indexer.Id; + _logger.Debug("Push Release {0} associated with indexer {1} - {2}.", release.Title, release.IndexerId, release.Indexer); + } + else + { + _logger.Debug("Push Release {0} not associated with unknown indexer {1}.", release.Title, release.Indexer); + } + } + else if (release.IndexerId != 0 && release.Indexer.IsNullOrWhiteSpace()) + { + try + { + var indexer = _indexerFactory.Get(release.IndexerId); + release.Indexer = indexer.Name; + _logger.Debug("Push Release {0} associated with indexer {1} - {2}.", release.Title, release.IndexerId, release.Indexer); + } + catch (ModelNotFoundException) + { + _logger.Debug("Push Release {0} not associated with unknown indexer {0}.", release.Title, release.IndexerId); + release.IndexerId = 0; + } + } + else + { + _logger.Debug("Push Release {0} not associated with an indexer.", release.Title); + } + } } } 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/ManualImport/ManualImportResource.cs b/src/NzbDrone.Api/ManualImport/ManualImportResource.cs index bc7b87408..1a779a410 100644 --- a/src/NzbDrone.Api/ManualImport/ManualImportResource.cs +++ b/src/NzbDrone.Api/ManualImport/ManualImportResource.cs @@ -13,6 +13,7 @@ namespace NzbDrone.Api.ManualImport { public string Path { get; set; } public string RelativePath { get; set; } + public string FolderName { get; set; } public string Name { get; set; } public long Size { get; set; } public SeriesResource Series { get; set; } @@ -36,6 +37,7 @@ namespace NzbDrone.Api.ManualImport Path = model.Path, RelativePath = model.RelativePath, + FolderName = model.FolderName, Name = model.Name, Size = model.Size, Series = model.Series.ToResource(), diff --git a/src/NzbDrone.Api/NancyBootstrapper.cs b/src/NzbDrone.Api/NancyBootstrapper.cs index 1415dd4c2..a34641659 100644 --- a/src/NzbDrone.Api/NancyBootstrapper.cs +++ b/src/NzbDrone.Api/NancyBootstrapper.cs @@ -34,7 +34,6 @@ namespace NzbDrone.Api RegisterPipelines(pipelines); container.Resolve().Register(); - container.Resolve().PublishEvent(new ApplicationStartedEvent()); } private void RegisterPipelines(IPipelines pipelines) @@ -56,4 +55,4 @@ namespace NzbDrone.Api protected override byte[] FavIcon => null; } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index cce77e637..dfa4bfb4e 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -70,12 +70,13 @@ True - ..\packages\NLog.4.4.3\lib\net40\NLog.dll + ..\packages\NLog.4.5.3\lib\net40-client\NLog.dll ..\packages\Ical.Net.2.2.32\lib\net40\NodaTime.dll + @@ -83,6 +84,10 @@ False ..\Libraries\Sqlite\System.Data.SQLite.dll + + + + @@ -99,13 +104,16 @@ + + + diff --git a/src/NzbDrone.Api/Parse/ParseModule.cs b/src/NzbDrone.Api/Parse/ParseModule.cs index df36307ff..266f66eb4 100644 --- a/src/NzbDrone.Api/Parse/ParseModule.cs +++ b/src/NzbDrone.Api/Parse/ParseModule.cs @@ -1,5 +1,6 @@ using NzbDrone.Api.Episodes; using NzbDrone.Api.Series; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Parser; namespace NzbDrone.Api.Parse @@ -18,7 +19,8 @@ namespace NzbDrone.Api.Parse private ParseResource Parse() { var title = Request.Query.Title.Value as string; - var parsedEpisodeInfo = Parser.ParseTitle(title); + var path = Request.Query.Path.Value as string; + var parsedEpisodeInfo = path.IsNotNullOrWhiteSpace() ? Parser.ParsePath(path) : Parser.ParseTitle(title); if (parsedEpisodeInfo == null) { diff --git a/src/NzbDrone.Api/ProviderModuleBase.cs b/src/NzbDrone.Api/ProviderModuleBase.cs index b45727227..d7ad2ec67 100644 --- a/src/NzbDrone.Api/ProviderModuleBase.cs +++ b/src/NzbDrone.Api/ProviderModuleBase.cs @@ -210,7 +210,12 @@ namespace NzbDrone.Api protected void VerifyValidationResult(ValidationResult validationResult, bool includeWarnings) { - var result = new NzbDroneValidationResult(validationResult.Errors); + var result = validationResult as NzbDroneValidationResult; + + if (result == null) + { + result = new NzbDroneValidationResult(validationResult.Errors); + } if (includeWarnings && (!result.IsValid || result.HasWarnings)) { diff --git a/src/NzbDrone.Api/RootFolders/RootFolderModule.cs b/src/NzbDrone.Api/RootFolders/RootFolderModule.cs index e87e581de..30303ab73 100644 --- a/src/NzbDrone.Api/RootFolders/RootFolderModule.cs +++ b/src/NzbDrone.Api/RootFolders/RootFolderModule.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation; using NzbDrone.Core.RootFolders; using NzbDrone.Core.Validation.Paths; @@ -17,7 +17,9 @@ namespace NzbDrone.Api.RootFolders DroneFactoryValidator droneFactoryValidator, MappedNetworkDriveValidator mappedNetworkDriveValidator, StartupFolderValidator startupFolderValidator, - FolderWritableValidator folderWritableValidator) + SystemFolderValidator systemFolderValidator, + FolderWritableValidator folderWritableValidator + ) : base(signalRBroadcaster) { _rootFolderService = rootFolderService; @@ -35,6 +37,7 @@ namespace NzbDrone.Api.RootFolders .SetValidator(mappedNetworkDriveValidator) .SetValidator(startupFolderValidator) .SetValidator(pathExistsValidator) + .SetValidator(systemFolderValidator) .SetValidator(folderWritableValidator); } @@ -60,4 +63,4 @@ namespace NzbDrone.Api.RootFolders _rootFolderService.Remove(id); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/RootFolders/RootFolderResource.cs b/src/NzbDrone.Api/RootFolders/RootFolderResource.cs index 86efef529..df55fcf1a 100644 --- a/src/NzbDrone.Api/RootFolders/RootFolderResource.cs +++ b/src/NzbDrone.Api/RootFolders/RootFolderResource.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NzbDrone.Api.REST; using NzbDrone.Core.RootFolders; @@ -9,6 +9,7 @@ namespace NzbDrone.Api.RootFolders { public string Path { get; set; } public long? FreeSpace { get; set; } + public long? TotalSpace { get; set; } public List UnmappedFolders { get; set; } } @@ -25,6 +26,7 @@ namespace NzbDrone.Api.RootFolders Path = model.Path, FreeSpace = model.FreeSpace, + TotalSpace = model.TotalSpace, UnmappedFolders = model.UnmappedFolders }; } @@ -48,4 +50,4 @@ namespace NzbDrone.Api.RootFolders return models.Select(ToResource).ToList(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Series/SeasonResource.cs b/src/NzbDrone.Api/Series/SeasonResource.cs index 4c20d8865..6115b8ab8 100644 --- a/src/NzbDrone.Api/Series/SeasonResource.cs +++ b/src/NzbDrone.Api/Series/SeasonResource.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using NzbDrone.Core.MediaCover; using NzbDrone.Core.Tv; namespace NzbDrone.Api.Series { @@ -8,18 +9,20 @@ namespace NzbDrone.Api.Series public int SeasonNumber { get; set; } public bool Monitored { get; set; } public SeasonStatisticsResource Statistics { get; set; } + public List Images { get; set; } } public static class SeasonResourceMapper { - public static SeasonResource ToResource(this Season model) + public static SeasonResource ToResource(this Season model, bool includeImages = false) { if (model == null) return null; return new SeasonResource { SeasonNumber = model.SeasonNumber, - Monitored = model.Monitored + Monitored = model.Monitored, + Images = includeImages ? model.Images : null }; } @@ -30,13 +33,14 @@ namespace NzbDrone.Api.Series return new Season { SeasonNumber = resource.SeasonNumber, - Monitored = resource.Monitored + Monitored = resource.Monitored, + Images = resource.Images }; } - public static List ToResource(this IEnumerable models) + public static List ToResource(this IEnumerable models, bool includeImages = false) { - return models.Select(ToResource).ToList(); + return models.Select(s => ToResource(s, includeImages)).ToList(); } public static List ToModel(this IEnumerable resources) diff --git a/src/NzbDrone.Api/Series/SeriesEditorModule.cs b/src/NzbDrone.Api/Series/SeriesEditorModule.cs index 87cd53113..d68fa7aa4 100644 --- a/src/NzbDrone.Api/Series/SeriesEditorModule.cs +++ b/src/NzbDrone.Api/Series/SeriesEditorModule.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using Nancy; using NzbDrone.Api.Extensions; @@ -24,7 +24,7 @@ namespace NzbDrone.Api.Series var series = resources.Select(seriesResource => seriesResource.ToModel(_seriesService.GetSeries(seriesResource.Id))).ToList(); return _seriesService.UpdateSeries(series) - .ToResource() + .ToResource(false) .AsResponse(HttpStatusCode.Accepted); } } diff --git a/src/NzbDrone.Api/Series/SeriesModule.cs b/src/NzbDrone.Api/Series/SeriesModule.cs index 0b33b9ee3..2ad1012e0 100644 --- a/src/NzbDrone.Api/Series/SeriesModule.cs +++ b/src/NzbDrone.Api/Series/SeriesModule.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentValidation; +using NzbDrone.Api.Extensions; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.MediaCover; @@ -45,6 +46,7 @@ namespace NzbDrone.Api.Series SeriesExistsValidator seriesExistsValidator, DroneFactoryValidator droneFactoryValidator, SeriesAncestorValidator seriesAncestorValidator, + SystemFolderValidator systemFolderValidator, ProfileExistsValidator profileExistsValidator ) : base(signalRBroadcaster) @@ -71,6 +73,7 @@ namespace NzbDrone.Api.Series .SetValidator(seriesPathValidator) .SetValidator(droneFactoryValidator) .SetValidator(seriesAncestorValidator) + .SetValidator(systemFolderValidator) .When(s => !s.Path.IsNullOrWhiteSpace()); SharedValidator.RuleFor(s => s.ProfileId).SetValidator(profileExistsValidator); @@ -84,26 +87,17 @@ namespace NzbDrone.Api.Series private SeriesResource GetSeries(int id) { + var includeSeasonImages = Context != null && Request.GetBooleanQueryParameter("includeSeasonImages"); + var series = _seriesService.GetSeries(id); - return MapToResource(series); - } - - private SeriesResource MapToResource(Core.Tv.Series series) - { - if (series == null) return null; - - var resource = series.ToResource(); - MapCoversToLocal(resource); - FetchAndLinkSeriesStatistics(resource); - PopulateAlternateTitles(resource); - - return resource; + return MapToResource(series, includeSeasonImages); } private List AllSeries() { + var includeSeasonImages = Request.GetBooleanQueryParameter("includeSeasonImages"); var seriesStats = _seriesStatisticsService.SeriesStatistics(); - var seriesResources = _seriesService.GetAllSeries().ToResource(); + var seriesResources = _seriesService.GetAllSeries().Select(s => s.ToResource(includeSeasonImages)).ToList(); MapCoversToLocal(seriesResources.ToArray()); LinkSeriesStatistics(seriesResources, seriesStats); @@ -141,6 +135,18 @@ namespace NzbDrone.Api.Series _seriesService.DeleteSeries(id, deleteFiles); } + private SeriesResource MapToResource(Core.Tv.Series series, bool includeSeasonImages) + { + if (series == null) return null; + + var resource = series.ToResource(includeSeasonImages); + MapCoversToLocal(resource); + FetchAndLinkSeriesStatistics(resource); + PopulateAlternateTitles(resource); + + return resource; + } + private void MapCoversToLocal(params SeriesResource[] series) { foreach (var seriesResource in series) diff --git a/src/NzbDrone.Api/Series/SeriesResource.cs b/src/NzbDrone.Api/Series/SeriesResource.cs index 86de03eb7..b973ad30d 100644 --- a/src/NzbDrone.Api/Series/SeriesResource.cs +++ b/src/NzbDrone.Api/Series/SeriesResource.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NzbDrone.Api.REST; @@ -97,7 +97,7 @@ namespace NzbDrone.Api.Series public static class SeriesResourceMapper { - public static SeriesResource ToResource(this Core.Tv.Series model) + public static SeriesResource ToResource(this Core.Tv.Series model, bool includeSeasonImages = false) { if (model == null) return null; @@ -121,7 +121,7 @@ namespace NzbDrone.Api.Series AirTime = model.AirTime, Images = model.Images, - Seasons = model.Seasons.ToResource(), + Seasons = model.Seasons.ToResource(includeSeasonImages), Year = model.Year, Path = model.Path, @@ -214,9 +214,9 @@ namespace NzbDrone.Api.Series return series; } - public static List ToResource(this IEnumerable series) + public static List ToResource(this IEnumerable series, bool includeSeasonImages) { - return series.Select(ToResource).ToList(); + return series.Select(s => ToResource(s, includeSeasonImages)).ToList(); } } } diff --git a/src/NzbDrone.Api/System/Backup/BackupModule.cs b/src/NzbDrone.Api/System/Backup/BackupModule.cs index b5074793e..8874ad420 100644 --- a/src/NzbDrone.Api/System/Backup/BackupModule.cs +++ b/src/NzbDrone.Api/System/Backup/BackupModule.cs @@ -21,9 +21,9 @@ namespace NzbDrone.Api.System.Backup return backups.Select(b => new BackupResource { - Id = b.Path.GetHashCode(), - Name = Path.GetFileName(b.Path), - Path = b.Path, + Id = b.Name.GetHashCode(), + Name = b.Name, + Path = $"/backup/{b.Type.ToString().ToLower()}/{b.Name}", Type = b.Type, Time = b.Time }).ToList(); diff --git a/src/NzbDrone.Api/packages.config b/src/NzbDrone.Api/packages.config index f68f69f6b..ba6f1b3ed 100644 --- a/src/NzbDrone.Api/packages.config +++ b/src/NzbDrone.Api/packages.config @@ -6,5 +6,5 @@ - + \ No newline at end of file diff --git a/src/NzbDrone.App.Test/NzbDrone.Host.Test.csproj b/src/NzbDrone.App.Test/NzbDrone.Host.Test.csproj index 1d6e658f9..5c28f26e2 100644 --- a/src/NzbDrone.App.Test/NzbDrone.Host.Test.csproj +++ b/src/NzbDrone.App.Test/NzbDrone.Host.Test.csproj @@ -47,20 +47,27 @@ ..\packages\FluentAssertions.4.19.0\lib\net40\FluentAssertions.Core.dll + + + ..\packages\Moq.4.0.10827\lib\NET40\Moq.dll + True + - ..\packages\NLog.4.4.3\lib\net40\NLog.dll + ..\packages\NLog.4.5.3\lib\net40-client\NLog.dll ..\packages\NUnit.3.6.0\lib\net40\nunit.framework.dll + + + + + - - ..\packages\Moq.4.0.10827\lib\NET40\Moq.dll - diff --git a/src/NzbDrone.App.Test/packages.config b/src/NzbDrone.App.Test/packages.config index dc7aef2ad..81a4765e8 100644 --- a/src/NzbDrone.App.Test/packages.config +++ b/src/NzbDrone.App.Test/packages.config @@ -1,8 +1,8 @@  - + - + \ No newline at end of file diff --git a/src/NzbDrone.Automation.Test/NzbDrone.Automation.Test.csproj b/src/NzbDrone.Automation.Test/NzbDrone.Automation.Test.csproj index d3861c667..72a909bbf 100644 --- a/src/NzbDrone.Automation.Test/NzbDrone.Automation.Test.csproj +++ b/src/NzbDrone.Automation.Test/NzbDrone.Automation.Test.csproj @@ -45,16 +45,20 @@ ..\packages\FluentAssertions.4.19.0\lib\net40\FluentAssertions.Core.dll - ..\packages\NLog.4.4.3\lib\net40\NLog.dll + ..\packages\NLog.4.5.3\lib\net40-client\NLog.dll ..\packages\NUnit.3.6.0\lib\net40\nunit.framework.dll + + + + diff --git a/src/NzbDrone.Automation.Test/packages.config b/src/NzbDrone.Automation.Test/packages.config index c5405c724..f92f63790 100644 --- a/src/NzbDrone.Automation.Test/packages.config +++ b/src/NzbDrone.Automation.Test/packages.config @@ -1,7 +1,7 @@  - + diff --git a/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs b/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs index d6c4faece..d90d8e53d 100644 --- a/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs +++ b/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Linq; using Moq; @@ -238,7 +238,7 @@ namespace NzbDrone.Common.Test.DiskTests WithExistingFile(_targetPath); - Assert.Throws(() => Subject.TransferFile(_sourcePath, _targetPath, TransferMode.Move, false)); + Assert.Throws(() => Subject.TransferFile(_sourcePath, _targetPath, TransferMode.Move, false)); Mocker.GetMock() .Verify(v => v.DeleteFile(_targetPath), Times.Never()); 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..49cdcc6e5 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() { @@ -139,7 +139,54 @@ namespace NzbDrone.Common.Test.Http var request = new HttpRequest($"http://{_httpBinHost}/redirect/1"); request.AllowAutoRedirect = true; - Subject.Get(request); + var response = Subject.Get(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + ExceptionVerification.ExpectedErrors(0); + } + + [Test] + public void should_not_follow_redirects() + { + var request = new HttpRequest($"http://{_httpBinHost}/redirect/1"); + request.AllowAutoRedirect = false; + + var response = Subject.Get(request); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + ExceptionVerification.ExpectedErrors(1); + } + + [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); } @@ -238,19 +285,96 @@ namespace NzbDrone.Common.Test.Http response.Resource.Headers.Should().NotContainKey("Cookie"); } + [Test] + public void should_not_store_request_cookie() + { + var requestGet = new HttpRequest($"http://{_httpBinHost}/get"); + requestGet.Cookies.Add("my", "cookie"); + requestGet.AllowAutoRedirect = false; + requestGet.StoreRequestCookie = false; + requestGet.StoreResponseCookie = false; + var responseGet = Subject.Get(requestGet); + + var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestCookies.AllowAutoRedirect = false; + var responseCookies = Subject.Get(requestCookies); + + responseCookies.Resource.Cookies.Should().BeEmpty(); + + ExceptionVerification.IgnoreErrors(); + } + + [Test] + public void should_store_request_cookie() + { + var requestGet = new HttpRequest($"http://{_httpBinHost}/get"); + requestGet.Cookies.Add("my", "cookie"); + requestGet.AllowAutoRedirect = false; + requestGet.StoreRequestCookie.Should().BeTrue(); + requestGet.StoreResponseCookie = false; + var responseGet = Subject.Get(requestGet); + + var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestCookies.AllowAutoRedirect = false; + var responseCookies = Subject.Get(requestCookies); + + responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); + + ExceptionVerification.IgnoreErrors(); + } + + [Test] + public void should_delete_request_cookie() + { + var requestDelete = new HttpRequest($"http://{_httpBinHost}/cookies/delete?my"); + requestDelete.Cookies.Add("my", "cookie"); + requestDelete.AllowAutoRedirect = true; + requestDelete.StoreRequestCookie = false; + requestDelete.StoreResponseCookie = false; + + // Delete and redirect since that's the only way to check the internal temporary cookie container + var responseCookies = Subject.Get(requestDelete); + + responseCookies.Resource.Cookies.Should().BeEmpty(); + } + + [Test] + public void should_clear_request_cookie() + { + var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestSet.Cookies.Add("my", "cookie"); + requestSet.AllowAutoRedirect = false; + requestSet.StoreRequestCookie = true; + requestSet.StoreResponseCookie = false; + + var responseSet = Subject.Get(requestSet); + + var requestClear = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestClear.Cookies.Add("my", null); + requestClear.AllowAutoRedirect = false; + requestClear.StoreRequestCookie = true; + requestClear.StoreResponseCookie = false; + + var responseClear = Subject.Get(requestClear); + + responseClear.Resource.Cookies.Should().BeEmpty(); + } + [Test] public void should_not_store_response_cookie() { var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); requestSet.AllowAutoRedirect = false; + requestSet.StoreRequestCookie = false; + requestSet.StoreResponseCookie.Should().BeFalse(); var responseSet = Subject.Get(requestSet); - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); - var response = Subject.Get(request); + var responseCookies = Subject.Get(requestCookies); - response.Resource.Headers.Should().NotContainKey("Cookie"); + responseCookies.Resource.Cookies.Should().BeEmpty(); ExceptionVerification.IgnoreErrors(); } @@ -260,19 +384,31 @@ namespace NzbDrone.Common.Test.Http { var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); requestSet.AllowAutoRedirect = false; + requestSet.StoreRequestCookie = false; requestSet.StoreResponseCookie = true; var responseSet = Subject.Get(requestSet); - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); - var response = Subject.Get(request); + var responseCookies = Subject.Get(requestCookies); - response.Resource.Headers.Should().ContainKey("Cookie"); + responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); - var cookie = response.Resource.Headers["Cookie"].ToString(); + ExceptionVerification.IgnoreErrors(); + } - cookie.Should().Contain("my=cookie"); + [Test] + public void should_temp_store_response_cookie() + { + var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); + requestSet.AllowAutoRedirect = true; + requestSet.StoreRequestCookie = false; + requestSet.StoreResponseCookie.Should().BeFalse(); + var responseSet = Subject.Get(requestSet); + + // Set and redirect since that's the only way to check the internal temporary cookie container + responseSet.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); ExceptionVerification.IgnoreErrors(); } @@ -281,21 +417,129 @@ namespace NzbDrone.Common.Test.Http public void should_overwrite_response_cookie() { var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); + requestSet.Cookies.Add("my", "oldcookie"); requestSet.AllowAutoRedirect = false; + requestSet.StoreRequestCookie = false; requestSet.StoreResponseCookie = true; - requestSet.Cookies["my"] = "oldcookie"; var responseSet = Subject.Get(requestSet); - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); - var response = Subject.Get(request); + var responseCookies = Subject.Get(requestCookies); - response.Resource.Headers.Should().ContainKey("Cookie"); + responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); - var cookie = response.Resource.Headers["Cookie"].ToString(); + ExceptionVerification.IgnoreErrors(); + } - cookie.Should().Contain("my=cookie"); + [Test] + public void should_overwrite_temp_response_cookie() + { + var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); + requestSet.Cookies.Add("my", "oldcookie"); + requestSet.AllowAutoRedirect = true; + requestSet.StoreRequestCookie = true; + requestSet.StoreResponseCookie = false; + + var responseSet = Subject.Get(requestSet); + + responseSet.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); + + var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + + var responseCookies = Subject.Get(requestCookies); + + responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "oldcookie"); + + ExceptionVerification.IgnoreErrors(); + } + + [Test] + public void should_not_delete_response_cookie() + { + var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestCookies.Cookies.Add("my", "cookie"); + requestCookies.AllowAutoRedirect = false; + requestCookies.StoreRequestCookie = true; + requestCookies.StoreResponseCookie = false; + var responseCookies = Subject.Get(requestCookies); + + responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); + + var requestDelete = new HttpRequest($"http://{_httpBinHost}/cookies/delete?my"); + requestDelete.AllowAutoRedirect = false; + requestDelete.StoreRequestCookie = false; + requestDelete.StoreResponseCookie = false; + + var responseDelete = Subject.Get(requestDelete); + + requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestCookies.StoreRequestCookie = false; + requestCookies.StoreResponseCookie = false; + + responseCookies = Subject.Get(requestCookies); + + responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); + + ExceptionVerification.IgnoreErrors(); + } + + [Test] + public void should_delete_response_cookie() + { + var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestCookies.Cookies.Add("my", "cookie"); + requestCookies.AllowAutoRedirect = false; + requestCookies.StoreRequestCookie = true; + requestCookies.StoreResponseCookie = false; + var responseCookies = Subject.Get(requestCookies); + + responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); + + var requestDelete = new HttpRequest($"http://{_httpBinHost}/cookies/delete?my"); + requestDelete.AllowAutoRedirect = false; + requestDelete.StoreRequestCookie = false; + requestDelete.StoreResponseCookie = true; + + var responseDelete = Subject.Get(requestDelete); + + requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestCookies.StoreRequestCookie = false; + requestCookies.StoreResponseCookie = false; + + responseCookies = Subject.Get(requestCookies); + + responseCookies.Resource.Cookies.Should().BeEmpty(); + + ExceptionVerification.IgnoreErrors(); + } + + [Test] + public void should_delete_temp_response_cookie() + { + var requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestCookies.Cookies.Add("my", "cookie"); + requestCookies.AllowAutoRedirect = false; + requestCookies.StoreRequestCookie = true; + requestCookies.StoreResponseCookie = false; + var responseCookies = Subject.Get(requestCookies); + + responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); + + var requestDelete = new HttpRequest($"http://{_httpBinHost}/cookies/delete?my"); + requestDelete.AllowAutoRedirect = true; + requestDelete.StoreRequestCookie = false; + requestDelete.StoreResponseCookie = false; + var responseDelete = Subject.Get(requestDelete); + + responseDelete.Resource.Cookies.Should().BeEmpty(); + + requestCookies = new HttpRequest($"http://{_httpBinHost}/cookies"); + requestCookies.StoreRequestCookie = false; + requestCookies.StoreResponseCookie = false; + + responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); ExceptionVerification.IgnoreErrors(); } @@ -407,4 +651,9 @@ namespace NzbDrone.Common.Test.Http public string Url { get; set; } public string Data { get; set; } } -} \ No newline at end of file + + public class HttpCookieResource + { + public Dictionary Cookies { get; set; } + } +} 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/InstrumentationTests/CleanseLogMessageFixture.cs b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs index 00c22cffb..b2ebdef47 100644 --- a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs +++ b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs @@ -15,6 +15,7 @@ namespace NzbDrone.Common.Test.InstrumentationTests [TestCase(@"https://rss.omgwtfnzbs.org/rss-search.php?catid=19,20&user=sonarr&api=mySecret&eng=1")] [TestCase(@"https://dognzb.cr/fetch/2b51db35e1912ffc138825a12b9933d2/2b51db35e1910123321025a12b9933d2")] [TestCase(@"https://baconbits.org/feeds.php?feed=torrents_tv&user=12345&auth=2b51db35e1910123321025a12b9933d2&passkey=mySecret&authkey=2b51db35e1910123321025a12b9933d2")] + [TestCase(@"http://127.0.0.1:9117/dl/indexername?jackett_apikey=flwjiefewklfjacketmySecretsdfldskjfsdlk&path=we0re9f0sdfbase64sfdkfjsdlfjk&file=The+Torrent+File+Name.torrent")] // NzbGet [TestCase(@"{ ""Name"" : ""ControlUsername"", ""Value"" : ""mySecret"" }, { ""Name"" : ""ControlPassword"", ""Value"" : ""mySecret"" }, ")] [TestCase(@"{ ""Name"" : ""Server1.Username"", ""Value"" : ""mySecret"" }, { ""Name"" : ""Server1.Password"", ""Value"" : ""mySecret"" }, ")] diff --git a/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj b/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj index 8f80dbe36..c6ed9935e 100644 --- a/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj +++ b/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj @@ -43,23 +43,28 @@ ..\packages\FluentAssertions.4.19.0\lib\net40\FluentAssertions.Core.dll + + ..\packages\Moq.4.0.10827\lib\NET40\Moq.dll + True + - ..\packages\NLog.4.4.3\lib\net40\NLog.dll + ..\packages\NLog.4.5.3\lib\net40-client\NLog.dll ..\packages\NUnit.3.6.0\lib\net40\nunit.framework.dll + + + + - - ..\packages\Moq.4.0.10827\lib\NET40\Moq.dll - @@ -80,6 +85,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.Test/packages.config b/src/NzbDrone.Common.Test/packages.config index a974434ae..69062660f 100644 --- a/src/NzbDrone.Common.Test/packages.config +++ b/src/NzbDrone.Common.Test/packages.config @@ -1,7 +1,7 @@  - - + + \ No newline at end of file diff --git a/src/NzbDrone.Common/ConsoleService.cs b/src/NzbDrone.Common/ConsoleService.cs index 321831277..262420f24 100644 --- a/src/NzbDrone.Common/ConsoleService.cs +++ b/src/NzbDrone.Common/ConsoleService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.IO; using NzbDrone.Common.EnvironmentInfo; @@ -24,6 +24,8 @@ namespace NzbDrone.Common Console.WriteLine(" /{0} Install the application as a Windows Service ({1}).", StartupContext.INSTALL_SERVICE, ServiceProvider.NZBDRONE_SERVICE_NAME); Console.WriteLine(" /{0} Uninstall already installed Windows Service ({1}).", StartupContext.UNINSTALL_SERVICE, ServiceProvider.NZBDRONE_SERVICE_NAME); Console.WriteLine(" /{0} Don't open Sonarr in a browser", StartupContext.NO_BROWSER); + Console.WriteLine(" /{0} Start Sonarr terminating any other instances", StartupContext.TERMINATE); + Console.WriteLine(" /{0}=path Path to use as the AppData location (stores database, config, logs, etc)", StartupContext.APPDATA); Console.WriteLine(" Run application in console mode."); } @@ -37,4 +39,4 @@ namespace NzbDrone.Common Console.WriteLine("Can't find service ({0})", ServiceProvider.NZBDRONE_SERVICE_NAME); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Disk/DestinationAlreadyExistsException.cs b/src/NzbDrone.Common/Disk/DestinationAlreadyExistsException.cs new file mode 100644 index 000000000..986413742 --- /dev/null +++ b/src/NzbDrone.Common/Disk/DestinationAlreadyExistsException.cs @@ -0,0 +1,29 @@ +using System; +using System.IO; +using System.Runtime.Serialization; + +namespace NzbDrone.Common.Disk +{ + public class DestinationAlreadyExistsException : IOException + { + public DestinationAlreadyExistsException() + { + } + + public DestinationAlreadyExistsException(string message) : base(message) + { + } + + public DestinationAlreadyExistsException(string message, int hresult) : base(message, hresult) + { + } + + public DestinationAlreadyExistsException(string message, Exception innerException) : base(message, innerException) + { + } + + protected DestinationAlreadyExistsException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + } +} diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index 6763709a5..ca3ead7cd 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -200,6 +200,11 @@ namespace NzbDrone.Common.Disk throw new IOException(string.Format("Source and destination can't be the same {0}", source)); } + CopyFileInternal(source, destination, overwrite); + } + + protected virtual void CopyFileInternal(string source, string destination, bool overwrite = false) + { File.Copy(source, destination, overwrite); } @@ -219,6 +224,11 @@ namespace NzbDrone.Common.Disk } RemoveReadOnly(source); + MoveFileInternal(source, destination); + } + + protected virtual void MoveFileInternal(string source, string destination) + { File.Move(source, destination); } diff --git a/src/NzbDrone.Common/Disk/DiskTransferService.cs b/src/NzbDrone.Common/Disk/DiskTransferService.cs index 3f93c11e4..3dedc38d0 100644 --- a/src/NzbDrone.Common/Disk/DiskTransferService.cs +++ b/src/NzbDrone.Common/Disk/DiskTransferService.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.IO; using System.Linq; using System.Threading; using NLog; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Exceptions; using NzbDrone.Common.Extensions; namespace NzbDrone.Common.Disk @@ -223,7 +224,7 @@ namespace NzbDrone.Common.Disk _diskProvider.MoveFile(sourcePath, tempPath, true); try { - ClearTargetPath(targetPath, overwrite); + ClearTargetPath(sourcePath, targetPath, overwrite); _diskProvider.MoveFile(tempPath, targetPath); @@ -253,7 +254,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 +331,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 +341,7 @@ namespace NzbDrone.Common.Disk } else { - throw new IOException(string.Format("Destination already exists [{0}]", targetPath)); + throw new DestinationAlreadyExistsException($"Destination {targetPath} already exists."); } } } @@ -590,7 +591,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..40f4fb6cd 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; @@ -47,7 +51,7 @@ namespace NzbDrone.Common.Disk { get { - if (VolumeLabel.IsNullOrWhiteSpace()) + if (VolumeLabel.IsNullOrWhiteSpace() || VolumeLabel.StartsWith("UUID=") || Name == VolumeLabel) { return Name; } 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/Exceptions/NotParentException.cs b/src/NzbDrone.Common/Disk/NotParentException.cs similarity index 79% rename from src/NzbDrone.Common/Exceptions/NotParentException.cs rename to src/NzbDrone.Common/Disk/NotParentException.cs index d9b78247e..0ae384722 100644 --- a/src/NzbDrone.Common/Exceptions/NotParentException.cs +++ b/src/NzbDrone.Common/Disk/NotParentException.cs @@ -1,4 +1,6 @@ -namespace NzbDrone.Common.Exceptions +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Common.Disk { public class NotParentException : NzbDroneException { diff --git a/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs b/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs index 7132d539f..1018cbe34 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs @@ -1,8 +1,9 @@ -using System; +using System; 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 @@ -27,12 +28,25 @@ namespace NzbDrone.Common.EnvironmentInfo public void Register() { - _diskProvider.EnsureFolder(_appFolderInfo.AppDataFolder); + try + { + _diskProvider.EnsureFolder(_appFolderInfo.AppDataFolder); + } + catch (UnauthorizedAccessException) + { + throw new SonarrStartupException("Cannot create AppFolder, Access to the path {0} is denied", _appFolderInfo.AppDataFolder); + } + if (OsInfo.IsWindows) { 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/EnvironmentInfo/IRuntimeInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/IRuntimeInfo.cs index cb432addc..d387001ef 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/IRuntimeInfo.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/IRuntimeInfo.cs @@ -1,5 +1,3 @@ -using System; - namespace NzbDrone.Common.EnvironmentInfo { public interface IRuntimeInfo @@ -7,8 +5,9 @@ namespace NzbDrone.Common.EnvironmentInfo bool IsUserInteractive { get; } bool IsAdmin { get; } bool IsWindowsService { get; } + bool IsWindowsTray { get; } bool IsExiting { get; set; } bool RestartPending { get; set; } string ExecutingApplication { get; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs index a53862311..3337b99b8 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.Diagnostics; using System.IO; using System.Reflection; using System.Security.Principal; using System.ServiceProcess; using NLog; +using NzbDrone.Common.Processes; namespace NzbDrone.Common.EnvironmentInfo { @@ -27,6 +28,7 @@ namespace NzbDrone.Common.EnvironmentInfo if (entry != null) { ExecutingApplication = entry.Location; + IsWindowsTray = entry.ManifestModule.Name == $"{ProcessProvider.NZB_DRONE_PROCESS_NAME}.exe"; } } @@ -102,5 +104,7 @@ namespace NzbDrone.Common.EnvironmentInfo return true; } + + public bool IsWindowsTray { get; private set; } } } 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..ec07e73ed --- /dev/null +++ b/src/NzbDrone.Common/Extensions/ExceptionExtensions.cs @@ -0,0 +1,67 @@ +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, maxSampleLength)); + + if (response.Request != null) + { + ex.AddData("RequestUri", response.Request.Url.ToString()); + + if (response.Request.ContentSummary != null) + { + ex.AddData("RequestSummary", response.Request.ContentSummary); + } + } + + ex.AddData("StatusCode", response.StatusCode.ToString()); + + 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/IEnumerableExtensions.cs b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs index a1beecaa9..2eeb2fe4e 100644 --- a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs +++ b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs @@ -51,6 +51,34 @@ namespace NzbDrone.Common.Extensions } } + public static Dictionary ToDictionaryIgnoreDuplicates(this IEnumerable src, Func keySelector) + { + var result = new Dictionary(); + foreach (var item in src) + { + var key = keySelector(item); + if (!result.ContainsKey(key)) + { + result[key] = item; + } + } + return result; + } + + public static Dictionary ToDictionaryIgnoreDuplicates(this IEnumerable src, Func keySelector, Func valueSelector) + { + var result = new Dictionary(); + foreach (var item in src) + { + var key = keySelector(item); + if (!result.ContainsKey(key)) + { + result[key] = valueSelector(item); + } + } + return result; + } + public static void AddIfNotNull(this List source, TSource item) { if (item == null) @@ -81,4 +109,4 @@ namespace NzbDrone.Common.Extensions return source.Select(predicate).ToList(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index e03f0a594..14da5fd51 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; @@ -59,7 +59,7 @@ namespace NzbDrone.Common.Extensions { if (!parentPath.IsParentPath(childPath)) { - throw new Exceptions.NotParentException("{0} is not a child of {1}", childPath, parentPath); + throw new NotParentException("{0} is not a child of {1}", childPath, parentPath); } return childPath.Substring(parentPath.Length).Trim(Path.DirectorySeparatorChar); @@ -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/TryParseExtensions.cs b/src/NzbDrone.Common/Extensions/TryParseExtensions.cs index c485fbd54..fe504b97b 100644 --- a/src/NzbDrone.Common/Extensions/TryParseExtensions.cs +++ b/src/NzbDrone.Common/Extensions/TryParseExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; namespace NzbDrone.Common.Extensions { @@ -6,7 +7,7 @@ namespace NzbDrone.Common.Extensions { public static int? ParseInt32(this string source) { - int result = 0; + int result; if (int.TryParse(source, out result)) { @@ -16,9 +17,9 @@ namespace NzbDrone.Common.Extensions return null; } - public static Nullable ParseInt64(this string source) + public static long? ParseInt64(this string source) { - long result = 0; + long result; if (long.TryParse(source, out result)) { @@ -27,5 +28,17 @@ namespace NzbDrone.Common.Extensions return null; } + + public static double? ParseDouble(this string source) + { + double result; + + if (double.TryParse(source.Replace(',', '.'), NumberStyles.Number, CultureInfo.InvariantCulture, out result)) + { + return result; + } + + return null; + } } -} \ 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/FallbackHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/FallbackHttpDispatcher.cs index 707004c9d..01b60e012 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/FallbackHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/FallbackHttpDispatcher.cs @@ -34,18 +34,11 @@ namespace NzbDrone.Common.Http.Dispatchers { return _managedDispatcher.GetResponse(request, cookies); } - catch (Exception ex) + catch (TlsFailureException) { - if (ex.ToString().Contains("The authentication or decryption has failed.")) - { - _logger.Debug("https request failed in tls error for {0}, trying curl fallback.", request.Url.Host); + _logger.Debug("https request failed in tls error for {0}, trying curl fallback.", request.Url.Host); - _curlTLSFallbackCache.Set(request.Url.Host, true); - } - else - { - throw; - } + _curlTLSFallbackCache.Set(request.Url.Host, true); } } diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 60231f75e..4a7269468 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) @@ -47,19 +47,19 @@ namespace NzbDrone.Common.Http.Dispatchers AddRequestHeaders(webRequest, request.Headers); } - if (request.ContentData != null) - { - webRequest.ContentLength = request.ContentData.Length; - using (var writeStream = webRequest.GetRequestStream()) - { - writeStream.Write(request.ContentData, 0, request.ContentData.Length); - } - } - HttpWebResponse httpWebResponse; try { + if (request.ContentData != null) + { + webRequest.ContentLength = request.ContentData.Length; + using (var writeStream = webRequest.GetRequestStream()) + { + writeStream.Write(request.ContentData, 0, request.ContentData.Length); + } + } + httpWebResponse = (HttpWebResponse)webRequest.GetResponse(); } catch (WebException e) @@ -73,7 +73,27 @@ 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 (e.ToString().Contains("TLS Support not")) + { + throw new TlsFailureException(webRequest, e); + } + else if (e.ToString().Contains("The authentication or decryption has failed.")) + { + throw new TlsFailureException(webRequest, e); + } + else if (OsInfo.IsNotWindows) + { + throw new WebException($"{e.Message}: '{webRequest.RequestUri}'", e, e.Status, e.Response); + } + else + { + throw; + } } } @@ -83,7 +103,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..45ff53ae3 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; @@ -51,46 +52,35 @@ namespace NzbDrone.Common.Http public HttpResponse Execute(HttpRequest request) { - foreach (var interceptor in _requestInterceptors) + var cookieContainer = InitializeRequestCookies(request); + + var response = ExecuteRequest(request, cookieContainer); + + if (request.AllowAutoRedirect && response.HasHttpRedirect) { - request = interceptor.PreRequest(request); + var autoRedirectChain = new List(); + autoRedirectChain.Add(request.Url.ToString()); + + do + { + request.Url += new HttpUri(response.Headers.GetSingleValue("Location")); + autoRedirectChain.Add(request.Url.ToString()); + + _logger.Trace("Redirected to {0}", request.Url); + + if (autoRedirectChain.Count > 3) + { + throw new WebException($"Too many automatic redirections were attempted for {autoRedirectChain.Join(" -> ")}", WebExceptionStatus.ProtocolError); + } + + response = ExecuteRequest(request, cookieContainer); + } + while (response.HasHttpRedirect); } - if (request.RateLimit != TimeSpan.Zero) + if (response.HasHttpRedirect && !RuntimeInfo.IsProduction) { - _rateLimitService.WaitAndPulse(request.Url.Host, request.RateLimit); - } - - _logger.Trace(request); - - var stopWatch = Stopwatch.StartNew(); - - var cookies = PrepareRequestCookies(request); - - var response = _httpDispatcher.GetResponse(request, cookies); - - HandleResponseCookies(request, cookies); - - stopWatch.Stop(); - - _logger.Trace("{0} ({1} ms)", response, stopWatch.ElapsedMilliseconds); - - foreach (var interceptor in _requestInterceptors) - { - response = interceptor.PostResponse(response); - } - - if (request.LogResponseContent) - { - _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"]); + _logger.Error("Server requested a redirect to [{0}] while in developer mode. Update the request URL to avoid this redirect.", response.Headers["Location"]); } if (!request.SuppressHttpError && response.HasHttpError) @@ -110,49 +100,130 @@ namespace NzbDrone.Common.Http return response; } - private CookieContainer PrepareRequestCookies(HttpRequest request) + private HttpResponse ExecuteRequest(HttpRequest request, CookieContainer cookieContainer) + { + foreach (var interceptor in _requestInterceptors) + { + request = interceptor.PreRequest(request); + } + + if (request.RateLimit != TimeSpan.Zero) + { + _rateLimitService.WaitAndPulse(request.Url.Host, request.RateLimit); + } + + _logger.Trace(request); + + var stopWatch = Stopwatch.StartNew(); + + PrepareRequestCookies(request, cookieContainer); + + var response = _httpDispatcher.GetResponse(request, cookieContainer); + + HandleResponseCookies(response, cookieContainer); + + stopWatch.Stop(); + + _logger.Trace("{0} ({1} ms)", response, stopWatch.ElapsedMilliseconds); + + foreach (var interceptor in _requestInterceptors) + { + response = interceptor.PostResponse(response); + } + + if (request.LogResponseContent) + { + _logger.Trace("Response content ({0} bytes): {1}", response.ResponseData.Length, response.Content); + } + + return response; + } + + private CookieContainer InitializeRequestCookies(HttpRequest request) { lock (_cookieContainerCache) { - var persistentCookieContainer = _cookieContainerCache.Get("container", () => new CookieContainer()); + var sourceContainer = new CookieContainer(); + + var presistentContainer = _cookieContainerCache.Get("container", () => new CookieContainer()); + var persistentCookies = presistentContainer.GetCookies((Uri)request.Url); + sourceContainer.Add(persistentCookies); if (request.Cookies.Count != 0) { foreach (var pair in request.Cookies) { - persistentCookieContainer.Add(new Cookie(pair.Key, pair.Value, "/", request.Url.Host) + Cookie cookie; + if (pair.Value == null) { - // Use Now rather than UtcNow to work around Mono cookie expiry bug. - // See https://gist.github.com/ta264/7822b1424f72e5b4c961 - Expires = DateTime.Now.AddHours(1) - }); + cookie = new Cookie(pair.Key, "", "/") + { + Expires = DateTime.Now.AddDays(-1) + }; + } + else + { + cookie = new Cookie(pair.Key, pair.Value, "/") + { + // Use Now rather than UtcNow to work around Mono cookie expiry bug. + // See https://gist.github.com/ta264/7822b1424f72e5b4c961 + Expires = DateTime.Now.AddHours(1) + }; + } + + sourceContainer.Add((Uri)request.Url, cookie); + + if (request.StoreRequestCookie) + { + presistentContainer.Add((Uri)request.Url, cookie); + } } } - var requestCookies = persistentCookieContainer.GetCookies((Uri)request.Url); - - var cookieContainer = new CookieContainer(); - - cookieContainer.Add(requestCookies); - - return cookieContainer; + return sourceContainer; } } - private void HandleResponseCookies(HttpRequest request, CookieContainer cookieContainer) + private void PrepareRequestCookies(HttpRequest request, CookieContainer cookieContainer) { - if (!request.StoreResponseCookie) + // Don't collect persistnet cookies for intermediate/redirected urls. + /*lock (_cookieContainerCache) + { + var presistentContainer = _cookieContainerCache.Get("container", () => new CookieContainer()); + var persistentCookies = presistentContainer.GetCookies((Uri)request.Url); + var existingCookies = cookieContainer.GetCookies((Uri)request.Url); + + cookieContainer.Add(persistentCookies); + cookieContainer.Add(existingCookies); + }*/ + } + + private void HandleResponseCookies(HttpResponse response, CookieContainer cookieContainer) + { + var cookieHeaders = response.GetCookieHeaders(); + if (cookieHeaders.Empty()) { return; } - lock (_cookieContainerCache) + if (response.Request.StoreResponseCookie) { - var persistentCookieContainer = _cookieContainerCache.Get("container", () => new CookieContainer()); + lock (_cookieContainerCache) + { + var persistentCookieContainer = _cookieContainerCache.Get("container", () => new CookieContainer()); - var cookies = cookieContainer.GetCookies((Uri)request.Url); - - persistentCookieContainer.Add(cookies); + foreach (var cookieHeader in cookieHeaders) + { + try + { + persistentCookieContainer.SetCookies((Uri)response.Request.Url, cookieHeader); + } + catch (Exception ex) + { + _logger.Debug(ex, "Invalid cookie in {0}", response.Request.Url); + } + } + } } } @@ -217,4 +288,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/HttpRequest.cs b/src/NzbDrone.Common/Http/HttpRequest.cs index 8f4b4472b..301890804 100644 --- a/src/NzbDrone.Common/Http/HttpRequest.cs +++ b/src/NzbDrone.Common/Http/HttpRequest.cs @@ -13,8 +13,10 @@ namespace NzbDrone.Common.Http Url = new HttpUri(url); Headers = new HttpHeader(); AllowAutoRedirect = true; + StoreRequestCookie = true; Cookies = new Dictionary(); - + + if (!RuntimeInfo.IsProduction) { AllowAutoRedirect = false; @@ -37,6 +39,7 @@ namespace NzbDrone.Common.Http public bool ConnectionKeepAlive { get; set; } public bool LogResponseContent { get; set; } public Dictionary Cookies { get; private set; } + public bool StoreRequestCookie { get; set; } public bool StoreResponseCookie { get; set; } public TimeSpan RequestTimeout { get; set; } public TimeSpan RateLimit { get; set; } @@ -76,5 +79,12 @@ namespace NzbDrone.Common.Http var encoding = HttpHeader.GetEncodingFromContentType(Headers.ContentType); ContentData = encoding.GetBytes(data); } + + public void AddBasicAuthentication(string username, string password) + { + var authInfo = Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes($"{username}:{password}")); + + Headers.Set("Authorization", "Basic " + authInfo); + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Http/HttpResponse.cs b/src/NzbDrone.Common/Http/HttpResponse.cs index dd9df22c7..e0b11b51c 100644 --- a/src/NzbDrone.Common/Http/HttpResponse.cs +++ b/src/NzbDrone.Common/Http/HttpResponse.cs @@ -35,7 +35,7 @@ namespace NzbDrone.Common.Http private string _content; - public string Content + public string Content { get { @@ -51,20 +51,26 @@ namespace NzbDrone.Common.Http public bool HasHttpError => (int)StatusCode >= 400; + public bool HasHttpRedirect => StatusCode == HttpStatusCode.Moved || + StatusCode == HttpStatusCode.MovedPermanently || + StatusCode == HttpStatusCode.Found; + + public string[] GetCookieHeaders() + { + return Headers.GetValues("Set-Cookie") ?? new string[0]; + } + public Dictionary GetCookies() { var result = new Dictionary(); - var setCookieHeaders = Headers.GetValues("Set-Cookie"); - if (setCookieHeaders != null) + var setCookieHeaders = GetCookieHeaders(); + foreach (var cookie in setCookieHeaders) { - foreach (var cookie in setCookieHeaders) + var match = RegexSetCookie.Match(cookie); + if (match.Success) { - var match = RegexSetCookie.Match(cookie); - if (match.Success) - { - result[match.Groups[1].Value] = match.Groups[2].Value; - } + result[match.Groups[1].Value] = match.Groups[2].Value; } } @@ -95,4 +101,4 @@ namespace NzbDrone.Common.Http public T Resource { get; private set; } } -} \ 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/Http/TlsFailureException.cs b/src/NzbDrone.Common/Http/TlsFailureException.cs new file mode 100644 index 000000000..c1dcdd991 --- /dev/null +++ b/src/NzbDrone.Common/Http/TlsFailureException.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; + +namespace NzbDrone.Common.Http +{ + public class TlsFailureException : WebException + { + public TlsFailureException(WebRequest request, WebException innerException) + : base("Failed to establish secure https connection to '" + request.RequestUri + "', libcurl fallback might be unavailable.", innerException, WebExceptionStatus.SecureChannelFailure, innerException.Response) + { + + } + + } +} diff --git a/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs b/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs index ef33968e5..a53e8e282 100644 --- a/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs +++ b/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs @@ -6,10 +6,10 @@ namespace NzbDrone.Common.Instrumentation { public class CleanseLogMessage { - private static readonly Regex[] CleansingRules = new[] + private static readonly Regex[] CleansingRules = new[] { // Url - new Regex(@"(?<=\?|&)(apikey|token|passkey|auth|authkey|user|uid|api)=(?[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"(?<=\?|&)(apikey|token|passkey|auth|authkey|user|uid|api|[a-z_]*apikey)=(?[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), new Regex(@"(?<=\?|&)[^=]*?(username|password)=(?[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), new Regex(@"torrentleech\.org/(?!rss)(?[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), new Regex(@"torrentleech\.org/rss/download/[0-9]+/(?[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), 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..3084d380b --- /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(logEvent.Properties.ToDictionary(v => v.Key, v => v.Value)) + .Exception(logEvent.Exception); + } + } +} diff --git a/src/NzbDrone.Common/Instrumentation/GlobalExceptionHandlers.cs b/src/NzbDrone.Common/Instrumentation/GlobalExceptionHandlers.cs index fbcbf4dcb..722b9823f 100644 --- a/src/NzbDrone.Common/Instrumentation/GlobalExceptionHandlers.cs +++ b/src/NzbDrone.Common/Instrumentation/GlobalExceptionHandlers.cs @@ -31,7 +31,7 @@ namespace NzbDrone.Common.Instrumentation if (exception is NullReferenceException && exception.ToString().Contains("Microsoft.AspNet.SignalR.Transports.TransportHeartbeat.ProcessServerCommand")) { - Logger.Warn("SignalR Heartbeat interupted"); + Logger.Warn("SignalR Heartbeat interrupted"); return; } @@ -49,4 +49,4 @@ namespace NzbDrone.Common.Instrumentation Logger.Fatal(exception, "EPIC FAIL."); } } -} \ No newline at end of file +} 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..34f7bec43 100644 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ b/src/NzbDrone.Common/NzbDrone.Common.csproj @@ -44,29 +44,30 @@ True - ..\packages\NLog.4.4.3\lib\net40\NLog.dll + ..\packages\NLog.4.5.3\lib\net40-client\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 + + ..\packages\ICSharpCode.SharpZipLib.Patched.0.86.5\lib\net20\ICSharpCode.SharpZipLib.dll + @@ -87,12 +88,15 @@ + + + @@ -125,7 +129,7 @@ - + @@ -136,6 +140,7 @@ + @@ -171,16 +176,20 @@ + - + + + + @@ -205,6 +214,8 @@ + + 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..17c625ef6 100644 --- a/src/NzbDrone.Common/Security/SecurityProtocolPolicy.cs +++ b/src/NzbDrone.Common/Security/SecurityProtocolPolicy.cs @@ -1,6 +1,7 @@ using System; using System.Net; using NLog; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Instrumentation; namespace NzbDrone.Common.Security @@ -14,6 +15,12 @@ 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. + 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/Serializer/UnderscoreStringEnumConverter.cs b/src/NzbDrone.Common/Serializer/UnderscoreStringEnumConverter.cs new file mode 100644 index 000000000..9022c029f --- /dev/null +++ b/src/NzbDrone.Common/Serializer/UnderscoreStringEnumConverter.cs @@ -0,0 +1,58 @@ +using System; +using System.Text; +using Newtonsoft.Json; + +namespace NzbDrone.Common.Serializer +{ + public class UnderscoreStringEnumConverter : JsonConverter + { + public object UnknownValue { get; set; } + + public UnderscoreStringEnumConverter(object unknownValue) + { + UnknownValue = unknownValue; + } + + public override bool CanConvert(Type objectType) + { + return objectType.IsEnum; + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var enumString = reader.Value.ToString().Replace("_", string.Empty); + + try + { + return Enum.Parse(objectType, enumString, true); + } + catch + { + if (UnknownValue == null) + { + throw; + } + + return UnknownValue; + } + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var enumText = value.ToString(); + var builder = new StringBuilder(enumText.Length + 4); + builder.Append(char.ToLower(enumText[0])); + for (int i = 1; i < enumText.Length; i++) + { + if (char.IsUpper(enumText[i])) + { + builder.Append('_'); + } + builder.Append(char.ToLower(enumText[i])); + } + enumText = builder.ToString(); + + writer.WriteValue(enumText); + } + } +} diff --git a/src/NzbDrone.Common/packages.config b/src/NzbDrone.Common/packages.config index c3f5e4d62..c1e8d1e7b 100644 --- a/src/NzbDrone.Common/packages.config +++ b/src/NzbDrone.Common/packages.config @@ -1,8 +1,8 @@  - + - + \ No newline at end of file diff --git a/src/NzbDrone.Console/ConsoleApp.cs b/src/NzbDrone.Console/ConsoleApp.cs index 6f935887f..39316bcd1 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,38 +12,83 @@ 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 { var startupArgs = new StartupContext(args); - NzbDroneLogger.Register(startupArgs, false, true); + try + { + NzbDroneLogger.Register(startupArgs, false, true); + } + catch (Exception ex) + { + System.Console.WriteLine("NLog Exception: " + ex.ToString()); + throw; + } 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."); - //Need this to terminate on mono (thanks nlog) - LogManager.Configuration = null; - Environment.Exit(0); + Exit(ExitCodes.Normal); + } + + private static void Exit(ExitCodes exitCode) + { + LogManager.Shutdown(); + + 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(); + } + + Environment.Exit((int)exitCode); } } } diff --git a/src/NzbDrone.Console/NzbDrone.Console.csproj b/src/NzbDrone.Console/NzbDrone.Console.csproj index 516b4fb2b..0b78bfdf8 100644 --- a/src/NzbDrone.Console/NzbDrone.Console.csproj +++ b/src/NzbDrone.Console/NzbDrone.Console.csproj @@ -66,6 +66,7 @@ app.manifest + False ..\packages\Microsoft.Owin.2.1.0\lib\net40\Microsoft.Owin.dll @@ -79,13 +80,19 @@ True - ..\packages\NLog.4.4.3\lib\net40\NLog.dll + ..\packages\NLog.4.5.3\lib\net40-client\NLog.dll + ..\packages\Owin.1.0\lib\net40\Owin.dll + + + + + diff --git a/src/NzbDrone.Console/packages.config b/src/NzbDrone.Console/packages.config index 11b77285e..f3188f1a4 100644 --- a/src/NzbDrone.Console/packages.config +++ b/src/NzbDrone.Console/packages.config @@ -3,6 +3,6 @@ - + \ No newline at end of file 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..99f522a79 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() { @@ -108,7 +108,7 @@ namespace NzbDrone.Core.Test.DataAugmentation.SceneNumbering .Verify(v => v.GetSceneTvdbMappings(10), Times.Never()); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Never()); + .Verify(v => v.UpdateSeries(It.IsAny(), It.IsAny()), Times.Never()); } [Test] @@ -119,7 +119,7 @@ namespace NzbDrone.Core.Test.DataAugmentation.SceneNumbering Subject.Handle(new SeriesUpdatedEvent(_series)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.UseSceneNumbering == true)), Times.Once()); + .Verify(v => v.UpdateSeries(It.Is(s => s.UseSceneNumbering == true), It.IsAny()), Times.Once()); } [Test] @@ -130,7 +130,7 @@ namespace NzbDrone.Core.Test.DataAugmentation.SceneNumbering Subject.Handle(new SeriesUpdatedEvent(_series)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Once()); + .Verify(v => v.UpdateSeries(It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -143,7 +143,7 @@ namespace NzbDrone.Core.Test.DataAugmentation.SceneNumbering Subject.Handle(new SeriesUpdatedEvent(_series)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Never()); + .Verify(v => v.UpdateSeries(It.IsAny(), It.IsAny()), Times.Never()); ExceptionVerification.ExpectedWarns(1); } @@ -160,7 +160,7 @@ namespace NzbDrone.Core.Test.DataAugmentation.SceneNumbering Subject.Handle(new SeriesUpdatedEvent(_series)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Never()); + .Verify(v => v.UpdateSeries(It.IsAny(), It.IsAny()), Times.Never()); ExceptionVerification.ExpectedWarns(1); } @@ -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/Datastore/Converters/BooleanIntConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/BooleanIntConverterFixture.cs new file mode 100644 index 000000000..649a7303e --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/BooleanIntConverterFixture.cs @@ -0,0 +1,59 @@ +using System; +using FluentAssertions; +using Marr.Data.Converters; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Converters +{ + [TestFixture] + public class BooleanIntConverterFixture : CoreTest + { + [TestCase(true, 1)] + [TestCase(false, 0)] + public void should_return_int_when_saving_boolean_to_db(bool input, int expected) + { + Subject.ToDB(input).Should().Be(expected); + } + + [Test] + public void should_return_db_null_for_null_value_when_saving_to_db() + { + Subject.ToDB(null).Should().Be(DBNull.Value); + } + + [TestCase(1, true)] + [TestCase(0, false)] + public void should_return_bool_when_getting_int_from_db(int input, bool expected) + { + var context = new ConverterContext + { + DbValue = (long)input + }; + + Subject.FromDB(context).Should().Be(expected); + } + + [Test] + public void should_return_db_null_for_null_value_when_getting_from_db() + { + var context = new ConverterContext + { + DbValue = DBNull.Value + }; + + Subject.FromDB(context).Should().Be(DBNull.Value); + } + + [Test] + public void should_throw_for_non_boolean_equivalent_number_value_when_getting_from_db() + { + var context = new ConverterContext + { + DbValue = (long)2 + }; + + Assert.Throws(() => Subject.FromDB(context)); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/CommandConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/CommandConverterFixture.cs new file mode 100644 index 000000000..ac0a3359e --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/CommandConverterFixture.cs @@ -0,0 +1,64 @@ +using System; +using System.Data; +using FluentAssertions; +using Marr.Data.Converters; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Converters; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv.Commands; + +namespace NzbDrone.Core.Test.Datastore.Converters +{ + [TestFixture] + public class CommandConverterFixture : CoreTest + { + [Test] + public void should_return_json_string_when_saving_boolean_to_db() + { + var command = new RefreshSeriesCommand(); + + Subject.ToDB(command).Should().BeOfType(); + } + + [Test] + public void should_return_null_for_null_value_when_saving_to_db() + { + Subject.ToDB(null).Should().Be(null); + } + + [Test] + public void should_return_db_null_for_db_null_value_when_saving_to_db() + { + Subject.ToDB(DBNull.Value).Should().Be(DBNull.Value); + } + + [Test] + public void should_return_command_when_getting_json_from_db() + { + var dataRecordMock = new Mock(); + dataRecordMock.Setup(s => s.GetOrdinal("Name")).Returns(0); + dataRecordMock.Setup(s => s.GetString(0)).Returns("RefreshSeries"); + + var context = new ConverterContext + { + DataRecord = dataRecordMock.Object, + DbValue = new RefreshSeriesCommand().ToJson() + }; + + Subject.FromDB(context).Should().BeOfType(); + } + + [Test] + public void should_return_null_for_null_value_when_getting_from_db() + { + var context = new ConverterContext + { + DbValue = DBNull.Value + }; + + Subject.FromDB(context).Should().Be(null); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/DoubleConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/DoubleConverterFixture.cs new file mode 100644 index 000000000..bf4974124 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/DoubleConverterFixture.cs @@ -0,0 +1,70 @@ +using System; +using FluentAssertions; +using Marr.Data.Converters; +using NUnit.Framework; +using NzbDrone.Core.Datastore.Converters; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Converters +{ + [TestFixture] + public class DoubleConverterFixture : CoreTest + { + [Test] + public void should_return_double_when_saving_double_to_db() + { + var input = 10.5D; + + Subject.ToDB(input).Should().Be(input); + } + + [Test] + public void should_return_null_for_null_value_when_saving_to_db() + { + Subject.ToDB(null).Should().Be(null); + } + + [Test] + public void should_return_db_null_for_db_null_value_when_saving_to_db() + { + Subject.ToDB(DBNull.Value).Should().Be(DBNull.Value); + } + + [Test] + public void should_return_double_when_getting_double_from_db() + { + var expected = 10.5D; + + var context = new ConverterContext + { + DbValue = expected + }; + + Subject.FromDB(context).Should().Be(expected); + } + + [Test] + public void should_return_double_when_getting_string_from_db() + { + var expected = 10.5D; + + var context = new ConverterContext + { + DbValue = $"{expected}" + }; + + Subject.FromDB(context).Should().Be(expected); + } + + [Test] + public void should_return_null_for_null_value_when_getting_from_db() + { + var context = new ConverterContext + { + DbValue = DBNull.Value + }; + + Subject.FromDB(context).Should().Be(DBNull.Value); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/EnumIntConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/EnumIntConverterFixture.cs new file mode 100644 index 000000000..d675086fb --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/EnumIntConverterFixture.cs @@ -0,0 +1,57 @@ +using System; +using System.Reflection; +using FluentAssertions; +using Marr.Data.Converters; +using Marr.Data.Mapping; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.Datastore.Converters +{ + [TestFixture] + public class EnumIntConverterFixture : CoreTest + { + [Test] + public void should_return_int_when_saving_enum_to_db() + { + Subject.ToDB(SeriesTypes.Standard).Should().Be((int)SeriesTypes.Standard); + } + + [Test] + public void should_return_db_null_for_null_value_when_saving_to_db() + { + Subject.ToDB(null).Should().Be(DBNull.Value); + } + + [Test] + public void should_return_enum_when_getting_int_from_db() + { + var mockMemberInfo = new Mock(); + mockMemberInfo.SetupGet(s => s.DeclaringType).Returns(typeof(Series)); + mockMemberInfo.SetupGet(s => s.Name).Returns("SeriesType"); + + var expected = SeriesTypes.Standard; + + var context = new ConverterContext + { + ColumnMap = new ColumnMap(mockMemberInfo.Object) { FieldType = typeof(SeriesTypes) }, + DbValue = (long)expected + }; + + Subject.FromDB(context).Should().Be(expected); + } + + [Test] + public void should_return_null_for_null_value_when_getting_from_db() + { + var context = new ConverterContext + { + DbValue = DBNull.Value + }; + + Subject.FromDB(context).Should().Be(null); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/GuidConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/GuidConverterFixture.cs new file mode 100644 index 000000000..8444fa053 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/GuidConverterFixture.cs @@ -0,0 +1,51 @@ +using System; +using FluentAssertions; +using Marr.Data.Converters; +using NUnit.Framework; +using NzbDrone.Core.Datastore.Converters; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Converters +{ + [TestFixture] + public class GuidConverterFixture : CoreTest + { + [Test] + public void should_return_string_when_saving_guid_to_db() + { + var guid = Guid.NewGuid(); + + Subject.ToDB(guid).Should().Be(guid.ToString()); + } + + [Test] + public void should_return_db_null_for_null_value_when_saving_to_db() + { + Subject.ToDB(null).Should().Be(DBNull.Value); + } + + [Test] + public void should_return_guid_when_getting_string_from_db() + { + var guid = Guid.NewGuid(); + + var context = new ConverterContext + { + DbValue = guid.ToString() + }; + + Subject.FromDB(context).Should().Be(guid); + } + + [Test] + public void should_return_empty_guid_for_db_null_value_when_getting_from_db() + { + var context = new ConverterContext + { + DbValue = DBNull.Value + }; + + Subject.FromDB(context).Should().Be(Guid.Empty); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/Int32ConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/Int32ConverterFixture.cs new file mode 100644 index 000000000..2c10a0aaf --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/Int32ConverterFixture.cs @@ -0,0 +1,58 @@ +using System; +using FluentAssertions; +using Marr.Data.Converters; +using NUnit.Framework; +using NzbDrone.Core.Datastore.Converters; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Converters +{ + [TestFixture] + public class Int32ConverterFixture : CoreTest + { + [Test] + public void should_return_int_when_saving_int_to_db() + { + var i = 5; + + Subject.ToDB(5).Should().Be(5); + } + + [Test] + public void should_return_int_when_getting_int_from_db() + { + var i = 5; + + var context = new ConverterContext + { + DbValue = i + }; + + Subject.FromDB(context).Should().Be(i); + } + + [Test] + public void should_return_int_when_getting_string_from_db() + { + var i = 5; + + var context = new ConverterContext + { + DbValue = i.ToString() + }; + + Subject.FromDB(context).Should().Be(i); + } + + [Test] + public void should_return_db_null_for_db_null_value_when_getting_from_db() + { + var context = new ConverterContext + { + DbValue = DBNull.Value + }; + + Subject.FromDB(context).Should().Be(DBNull.Value); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/OsPathConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/OsPathConverterFixture.cs new file mode 100644 index 000000000..f7f3da0d8 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/OsPathConverterFixture.cs @@ -0,0 +1,49 @@ +using System; +using FluentAssertions; +using Marr.Data.Converters; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Datastore.Converters; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Datastore.Converters +{ + [TestFixture] + public class OsPathConverterFixture : CoreTest + { + [Test] + public void should_return_string_when_saving_os_path_to_db() + { + var path = @"C:\Test\TV".AsOsAgnostic(); + var osPath = new OsPath(path); + + Subject.ToDB(osPath).Should().Be(path); + } + + [Test] + public void should_return_os_path_when_getting_string_from_db() + { + var path = @"C:\Test\TV".AsOsAgnostic(); + var osPath = new OsPath(path); + + var context = new ConverterContext + { + DbValue = path + }; + + Subject.FromDB(context).Should().Be(osPath); + } + + [Test] + public void should_return_db_null_for_db_null_value_when_getting_from_db() + { + var context = new ConverterContext + { + DbValue = DBNull.Value + }; + + Subject.FromDB(context).Should().Be(DBNull.Value); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/QualityIntConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/QualityIntConverterFixture.cs new file mode 100644 index 000000000..31ae8b6b3 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/QualityIntConverterFixture.cs @@ -0,0 +1,58 @@ +using System; +using FluentAssertions; +using Marr.Data.Converters; +using NUnit.Framework; +using NzbDrone.Core.Datastore.Converters; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Converters +{ + [TestFixture] + public class QualityIntConverterFixture : CoreTest + { + [Test] + public void should_return_int_when_saving_quality_to_db() + { + var quality = Quality.Bluray1080p; + + Subject.ToDB(quality).Should().Be(quality.Id); + } + + [Test] + public void should_return_0_when_saving_db_null_to_db() + { + Subject.ToDB(DBNull.Value).Should().Be(0); + } + + [Test] + public void should_throw_when_saving_another_object_to_db() + { + Assert.Throws(() => Subject.ToDB("Not a quality")); + } + + [Test] + public void should_return_quality_when_getting_string_from_db() + { + var quality = Quality.Bluray1080p; + + var context = new ConverterContext + { + DbValue = quality.Id + }; + + Subject.FromDB(context).Should().Be(quality); + } + + [Test] + public void should_return_db_null_for_db_null_value_when_getting_from_db() + { + var context = new ConverterContext + { + DbValue = DBNull.Value + }; + + Subject.FromDB(context).Should().Be(Quality.Unknown); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/TimeSpanConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/TimeSpanConverterFixture.cs new file mode 100644 index 000000000..c96848179 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/TimeSpanConverterFixture.cs @@ -0,0 +1,65 @@ +using System; +using System.Globalization; +using FluentAssertions; +using Marr.Data.Converters; +using NUnit.Framework; +using NzbDrone.Core.Datastore.Converters; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Converters +{ + [TestFixture] + public class TimeSpanConverterFixture : CoreTest + { + [Test] + public void should_return_string_when_saving_timespan_to_db() + { + var timeSpan = TimeSpan.FromMinutes(5); + + Subject.ToDB(timeSpan).Should().Be(timeSpan.ToString("c", CultureInfo.InvariantCulture)); + } + + [Test] + public void should_return_null_when_saving_empty_string_to_db() + { + Subject.ToDB("").Should().Be(null); + } + + [Test] + public void should_return_time_span_when_getting_time_span_from_db() + { + var timeSpan = TimeSpan.FromMinutes(5); + + var context = new ConverterContext + { + DbValue = timeSpan + }; + + Subject.FromDB(context).Should().Be(timeSpan); + } + + [Test] + public void should_return_time_span_when_getting_string_from_db() + { + var timeSpan = TimeSpan.FromMinutes(5); + + var context = new ConverterContext + { + DbValue = timeSpan.ToString("c", CultureInfo.InvariantCulture) + }; + + Subject.FromDB(context).Should().Be(timeSpan); + } + + [Test] + public void should_return_time_span_zero_for_db_null_value_when_getting_from_db() + { + var context = new ConverterContext + { + DbValue = DBNull.Value + }; + + Subject.FromDB(context).Should().Be(TimeSpan.Zero); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/UtcConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/UtcConverterFixture.cs new file mode 100644 index 000000000..904f653d3 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/UtcConverterFixture.cs @@ -0,0 +1,51 @@ +using System; +using FluentAssertions; +using Marr.Data.Converters; +using NUnit.Framework; +using NzbDrone.Core.Datastore.Converters; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Converters +{ + [TestFixture] + public class UtcConverterFixture : CoreTest + { + [Test] + public void should_return_date_time_when_saving_date_time_to_db() + { + var dateTime = DateTime.Now; + + Subject.ToDB(dateTime).Should().Be(dateTime.ToUniversalTime()); + } + + [Test] + public void should_return_db_null_when_saving_db_null_to_db() + { + Subject.ToDB(DBNull.Value).Should().Be(DBNull.Value); + } + + [Test] + public void should_return_time_span_when_getting_time_span_from_db() + { + var dateTime = DateTime.Now.ToUniversalTime(); + + var context = new ConverterContext + { + DbValue = dateTime + }; + + Subject.FromDB(context).Should().Be(dateTime); + } + + [Test] + public void should_return_db_null_for_db_null_value_when_getting_from_db() + { + var context = new ConverterContext + { + DbValue = DBNull.Value + }; + + Subject.FromDB(context).Should().Be(DBNull.Value); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/121_update_animetosho_urlFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/121_update_animetosho_urlFixture.cs new file mode 100644 index 000000000..33bdddddd --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/121_update_animetosho_urlFixture.cs @@ -0,0 +1,41 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class update_animetosho_urlFixture : MigrationTest + { + [TestCase("Newznab", "https://animetosho.org")] + [TestCase("Newznab", "http://animetosho.org")] + [TestCase("Torznab", "https://animetosho.org")] + [TestCase("Torznab", "http://animetosho.org")] + public void should_replace_old_url(string impl, string baseUrl) + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Indexers").Row(new + { + Name = "AnimeTosho", + Implementation = impl, + Settings = new NewznabSettings121 + { + BaseUrl = baseUrl, + ApiPath = "/feed/nabapi" + + }.ToJson(), + ConfigContract = impl + "Settings" + }); + }); + + var items = db.Query("SELECT * FROM Indexers"); + + items.Should().HaveCount(1); + items.First().Settings.ToObject().BaseUrl.Should().Be(baseUrl.Replace("animetosho", "feed.animetosho")); + } + } +} 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/FullSeasonSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/FullSeasonSpecificationFixture.cs index 6a66d957d..a751d2a6a 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/FullSeasonSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/FullSeasonSpecificationFixture.cs @@ -49,27 +49,27 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { _remoteEpisode.ParsedEpisodeInfo.FullSeason = false; _remoteEpisode.Episodes.Last().AirDateUtc = DateTime.UtcNow.AddDays(+2); - Mocker.Resolve().IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [Test] public void should_return_true_if_all_episodes_have_aired() { - Mocker.Resolve().IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [Test] public void should_return_false_if_one_episode_has_not_aired() { _remoteEpisode.Episodes.Last().AirDateUtc = DateTime.UtcNow.AddDays(+2); - Mocker.Resolve().IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } [Test] public void should_return_false_if_an_episode_does_not_have_an_air_date() { _remoteEpisode.Episodes.Last().AirDateUtc = null; - Mocker.Resolve().IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/MaximumSizeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/MaximumSizeSpecificationFixture.cs new file mode 100644 index 000000000..1b8d7ab37 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/MaximumSizeSpecificationFixture.cs @@ -0,0 +1,75 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + public class MaximumSizeSpecificationFixture : CoreTest + { + private RemoteEpisode _remoteEpisode; + + [SetUp] + public void Setup() + { + _remoteEpisode = new RemoteEpisode() { Release = new ReleaseInfo() }; + } + + private void WithMaximumSize(int size) + { + Mocker.GetMock().SetupGet(c => c.MaximumSize).Returns(size); + } + + private void WithSize(int size) + { + _remoteEpisode.Release.Size = size * 1024 * 1024; + } + + [Test] + public void should_return_true_when_maximum_size_is_set_to_zero() + { + WithMaximumSize(0); + WithSize(1000); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_size_is_smaller_than_maximum_size() + { + WithMaximumSize(2000); + WithSize(1999); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_size_is_equals_to_maximum_size() + { + WithMaximumSize(2000); + WithSize(2000); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_when_size_is_bigger_than_maximum_size() + { + WithMaximumSize(2000); + WithSize(2001); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_true_when_size_is_zero() + { + WithMaximumSize(2000); + WithSize(0); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + } +} 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/ReleaseRestrictionsSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs index 5ccaaaedb..255920d39 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs @@ -29,6 +29,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Title = "Dexter.S08E01.EDITED.WEBRip.x264-KYR" } }; + + Mocker.SetConstant(Mocker.Resolve()); } private void GivenRestictions(string required, string ignored) @@ -123,5 +125,16 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } + + [TestCase("/WEB/", true)] + [TestCase("/WEB\b/", false)] + [TestCase("/WEb/", false)] + [TestCase(@"/\.WEB/", true)] + public void should_match_perl_regex(string pattern, bool expected) + { + GivenRestictions(pattern, null); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().Be(expected); + } } } 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..6dd6f2aec --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/Search/TorrentSeedingSpecificationFixture.cs @@ -0,0 +1,110 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.TorrentRss; +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/DiskSpace/DiskSpaceServiceFixture.cs b/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs index d7650c204..0953f6b59 100644 --- a/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs +++ b/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs @@ -130,5 +130,26 @@ namespace NzbDrone.Core.Test.DiskSpace Mocker.GetMock() .Verify(v => v.GetAvailableSpace(It.IsAny()), Times.Never()); } + + [TestCase("/boot")] + [TestCase("/var/lib/rancher")] + [TestCase("/var/lib/rancher/volumes")] + [TestCase("/var/lib/kubelet")] + [TestCase("/var/lib/docker")] + [TestCase("/some/place/docker/aufs")] + public void should_not_check_diskspace_for_irrelevant_mounts(string path) + { + var mount = new Mock(); + mount.SetupGet(v => v.RootDirectory).Returns(path); + mount.SetupGet(v => v.DriveType).Returns(System.IO.DriveType.Fixed); + + Mocker.GetMock() + .Setup(v => v.GetMounts()) + .Returns(new List { mount.Object }); + + var freeSpace = Subject.GetFreeSpace(); + + freeSpace.Should().BeEmpty(); + } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs index 76d22d669..391d50410 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs @@ -6,7 +6,10 @@ 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.Exceptions; +using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; @@ -35,7 +38,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 +48,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() @@ -175,7 +179,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests } [Test] - public void should_return_an_empty_list_when_none_are_appproved() + public void should_return_an_empty_list_when_none_are_approved() { var decisions = new List(); decisions.Add(new DownloadDecision(null, new Rejection("Failure!"))); @@ -192,7 +196,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 +212,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.AddMany(It.IsAny>>()), Times.Never()); } [Test] @@ -223,7 +226,64 @@ 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.AddMany(It.IsAny>>()), Times.Once()); + } + + [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()); + } + + [Test] + public void should_add_to_rejected_if_release_unavailable_on_indexer() + { + var episodes = new List { GetEpisode(1) }; + var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p)); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteEpisode)); + + Mocker.GetMock() + .Setup(s => s.DownloadReport(It.IsAny())) + .Throws(new ReleaseUnavailableException(remoteEpisode.Release, "That 404 Error is not just a Quirk")); + + var result = Subject.ProcessDecisions(decisions); + + result.Grabbed.Should().BeEmpty(); + result.Rejected.Should().NotBeEmpty(); + + ExceptionVerification.ExpectedWarns(1); } } } 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/DownloadStationsTaskStatusJsonConverterFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/DownloadStationsTaskStatusJsonConverterFixture.cs new file mode 100644 index 000000000..0ad41bbfd --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/DownloadStationsTaskStatusJsonConverterFixture.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using NzbDrone.Core.Download.Clients.DownloadStation; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests +{ + [TestFixture] + public class DownloadStationsTaskStatusJsonConverterFixture + { + [TestCase("captcha_needed", DownloadStationTaskStatus.CaptchaNeeded)] + [TestCase("filehosting_waiting", DownloadStationTaskStatus.FilehostingWaiting)] + [TestCase("hash_checking", DownloadStationTaskStatus.HashChecking)] + [TestCase("error", DownloadStationTaskStatus.Error)] + [TestCase("downloading", DownloadStationTaskStatus.Downloading)] + public void should_parse_enum_correctly(string value, DownloadStationTaskStatus expected) + { + var task = "{\"Status\": \"" + value + "\"}"; + + var item = JsonConvert.DeserializeObject(task); + + item.Status.Should().Be(expected); + } + + [TestCase("captcha_needed", DownloadStationTaskStatus.CaptchaNeeded)] + [TestCase("filehosting_waiting", DownloadStationTaskStatus.FilehostingWaiting)] + [TestCase("hash_checking", DownloadStationTaskStatus.HashChecking)] + [TestCase("error", DownloadStationTaskStatus.Error)] + [TestCase("downloading", DownloadStationTaskStatus.Downloading)] + public void should_serialize_enum_correctly(string expected, DownloadStationTaskStatus value) + { + var task = new DownloadStationTask { Status = value }; + + var item = JsonConvert.SerializeObject(task); + + item.Should().Contain(expected); + } + + [Test] + public void should_return_unknown_if_unknown_enum_value() + { + var task = "{\"Status\": \"some_unknown_value\"}"; + + var item = JsonConvert.DeserializeObject(task); + + item.Status.Should().Be(DownloadStationTaskStatus.Unknown); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/TorrentDownloadStationFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/TorrentDownloadStationFixture.cs index 8269acda6..ed9bc321f 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/TorrentDownloadStationFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/TorrentDownloadStationFixture.cs @@ -73,6 +73,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "0"}, + { "size_uploaded", "0"}, { "speed_download", "0" } } } @@ -96,6 +97,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } }, } @@ -119,6 +121,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } } } @@ -142,6 +145,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "100"}, + { "size_uploaded", "10"}, { "speed_download", "50" } } } @@ -165,6 +169,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "10"}, + { "size_uploaded", "1"}, { "speed_download", "0" } } } @@ -188,6 +193,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } } } @@ -211,6 +217,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } } } @@ -234,6 +241,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } } } @@ -257,6 +265,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } } } @@ -576,11 +585,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 +601,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)] @@ -601,9 +614,12 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests [TestCase(DownloadStationTaskStatus.Finished, DownloadItemStatus.Completed)] [TestCase(DownloadStationTaskStatus.Finishing, DownloadItemStatus.Downloading)] [TestCase(DownloadStationTaskStatus.HashChecking, DownloadItemStatus.Downloading)] + [TestCase(DownloadStationTaskStatus.CaptchaNeeded, DownloadItemStatus.Downloading)] [TestCase(DownloadStationTaskStatus.Paused, DownloadItemStatus.Paused)] [TestCase(DownloadStationTaskStatus.Seeding, DownloadItemStatus.Completed)] + [TestCase(DownloadStationTaskStatus.FilehostingWaiting, DownloadItemStatus.Queued)] [TestCase(DownloadStationTaskStatus.Waiting, DownloadItemStatus.Queued)] + [TestCase(DownloadStationTaskStatus.Unknown, DownloadItemStatus.Queued)] public void GetItems_should_return_item_as_downloadItemStatus(DownloadStationTaskStatus apiStatus, DownloadItemStatus expectedItemStatus) { GivenSerialNumber(); diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/UsenetDownloadStationFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/UsenetDownloadStationFixture.cs index 48df65841..14c94e808 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/UsenetDownloadStationFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/UsenetDownloadStationFixture.cs @@ -408,32 +408,18 @@ 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)] [TestCase(DownloadStationTaskStatus.Finished, DownloadItemStatus.Completed)] [TestCase(DownloadStationTaskStatus.Finishing, DownloadItemStatus.Downloading)] [TestCase(DownloadStationTaskStatus.HashChecking, DownloadItemStatus.Downloading)] + [TestCase(DownloadStationTaskStatus.CaptchaNeeded, DownloadItemStatus.Downloading)] [TestCase(DownloadStationTaskStatus.Paused, DownloadItemStatus.Paused)] + [TestCase(DownloadStationTaskStatus.Seeding, DownloadItemStatus.Completed)] + [TestCase(DownloadStationTaskStatus.FilehostingWaiting, DownloadItemStatus.Queued)] [TestCase(DownloadStationTaskStatus.Waiting, DownloadItemStatus.Queued)] + [TestCase(DownloadStationTaskStatus.Unknown, DownloadItemStatus.Queued)] public void GetItems_should_return_item_as_downloadItemStatus(DownloadStationTaskStatus apiStatus, DownloadItemStatus expectedItemStatus) { GivenSerialNumber(); 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/DownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs index b82216b19..1b49ef811 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs @@ -22,6 +22,7 @@ namespace NzbDrone.Core.Test.Download { private RemoteEpisode _parseResult; private List _downloadClients; + [SetUp] public void Setup() { @@ -82,7 +83,7 @@ namespace NzbDrone.Core.Test.Download { var mock = WithUsenetClient(); mock.Setup(s => s.Download(It.IsAny())); - + Subject.DownloadReport(_parseResult); VerifyEventPublished(); @@ -93,7 +94,7 @@ namespace NzbDrone.Core.Test.Download { var mock = WithUsenetClient(); mock.Setup(s => s.Download(It.IsAny())); - + Subject.DownloadReport(_parseResult); mock.Verify(s => s.Download(It.IsAny()), Times.Once()); @@ -117,7 +118,7 @@ namespace NzbDrone.Core.Test.Download var mock = WithUsenetClient(); mock.Setup(s => s.Download(It.IsAny())) .Callback(v => { - throw new ReleaseDownloadException(v.Release, "Error", new WebException()); + throw new ReleaseDownloadException(v.Release, "Error", new WebException()); }); Assert.Throws(() => Subject.DownloadReport(_parseResult)); @@ -136,7 +137,7 @@ namespace NzbDrone.Core.Test.Download var mock = WithUsenetClient(); mock.Setup(s => s.Download(It.IsAny())) .Callback(v => { - throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response)); + throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response)); }); Assert.Throws(() => Subject.DownloadReport(_parseResult)); @@ -180,14 +181,50 @@ namespace NzbDrone.Core.Test.Download } [Test] - public void should_not_attempt_download_if_client_isnt_configure() + public void Download_report_should_not_trigger_indexer_backoff_on_indexer_404_error() { - Subject.DownloadReport(_parseResult); + var mock = WithUsenetClient(); + mock.Setup(s => s.Download(It.IsAny())) + .Callback(v => { + throw new ReleaseUnavailableException(v.Release, "Error", new WebException()); + }); + + Assert.Throws(() => Subject.DownloadReport(_parseResult)); + + Mocker.GetMock() + .Verify(v => v.RecordFailure(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Test] + public void should_not_attempt_download_if_client_isnt_configured() + { + Assert.Throws(() => Subject.DownloadReport(_parseResult)); Mocker.GetMock().Verify(c => c.Download(It.IsAny()), Times.Never()); VerifyEventNotPublished(); + } - ExceptionVerification.ExpectedWarns(1); + [Test] + public void should_attempt_download_even_if_client_is_disabled() + { + var mockUsenet = WithUsenetClient(); + + Mocker.GetMock() + .Setup(v => v.GetBlockedProviders()) + .Returns(new List + { + new DownloadClientStatus + { + ProviderId = _downloadClients.First().Definition.Id, + DisabledTill = DateTime.UtcNow.AddHours(3) + } + }); + + Subject.DownloadReport(_parseResult); + + Mocker.GetMock().Verify(c => c.GetBlockedProviders(), Times.Never()); + mockUsenet.Verify(c => c.Download(It.IsAny()), Times.Once()); + VerifyEventPublished(); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/NzbValidationServiceFixture.cs b/src/NzbDrone.Core.Test/Download/NzbValidationServiceFixture.cs new file mode 100644 index 000000000..557a28ae0 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/NzbValidationServiceFixture.cs @@ -0,0 +1,43 @@ +using System.IO; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Download +{ + [TestFixture] + public class NzbValidationServiceFixture : CoreTest + { + private byte[] GivenNzbFile(string name) + { + return File.ReadAllBytes(GetTestPath("Files/Nzbs/" + name + ".nzb")); + } + + [Test] + public void should_throw_on_invalid_nzb() + { + var filename = "NotNzb"; + var fileContent = GivenNzbFile(filename); + + Assert.Throws(() => Subject.Validate(filename, fileContent)); + } + + [Test] + public void should_throw_when_no_files() + { + var filename = "NoFiles"; + var fileContent = GivenNzbFile(filename); + + Assert.Throws(() => Subject.Validate(filename, fileContent)); + } + + [Test] + public void should_validate_nzb() + { + var filename = "ValidNzb"; + var fileContent = GivenNzbFile(filename); + + Subject.Validate(filename, fileContent); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs index 2a5a29c6b..5820b1f0b 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using FizzWare.NBuilder; using Marr.Data; using Moq; @@ -26,6 +27,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests private ReleaseInfo _release; private ParsedEpisodeInfo _parsedEpisodeInfo; private RemoteEpisode _remoteEpisode; + private List _heldReleases; [SetUp] public void Setup() @@ -60,17 +62,27 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests _remoteEpisode.Series = _series; _remoteEpisode.ParsedEpisodeInfo = _parsedEpisodeInfo; _remoteEpisode.Release = _release; - + _temporarilyRejected = new DownloadDecision(_remoteEpisode, new Rejection("Temp Rejected", RejectionType.Temporary)); + _heldReleases = new List(); + Mocker.GetMock() .Setup(s => s.All()) - .Returns(new List()); + .Returns(_heldReleases); + + Mocker.GetMock() + .Setup(s => s.AllBySeriesId(It.IsAny())) + .Returns(i => _heldReleases.Where(v => v.SeriesId == i).ToList()); Mocker.GetMock() .Setup(s => s.GetSeries(It.IsAny())) .Returns(_series); + Mocker.GetMock() + .Setup(s => s.GetSeries(It.IsAny>())) + .Returns(new List { _series }); + Mocker.GetMock() .Setup(s => s.GetEpisodes(It.IsAny(), _series, true, null)) .Returns(new List {_episode}); @@ -80,7 +92,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests .Returns((List d) => d); } - private void GivenHeldRelease(string title, string indexer, DateTime publishDate) + private void GivenHeldRelease(string title, string indexer, DateTime publishDate, PendingReleaseReason reason = PendingReleaseReason.Delay) { var release = _release.JsonClone(); release.Indexer = indexer; @@ -92,17 +104,16 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests .With(h => h.SeriesId = _series.Id) .With(h => h.Title = title) .With(h => h.Release = release) + .With(h => h.Reason = reason) .Build(); - Mocker.GetMock() - .Setup(s => s.All()) - .Returns(heldReleases); + _heldReleases.AddRange(heldReleases); } [Test] public void should_add() { - Subject.Add(_temporarilyRejected); + Subject.Add(_temporarilyRejected, PendingReleaseReason.Delay); VerifyInsert(); } @@ -112,17 +123,40 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { GivenHeldRelease(_release.Title, _release.Indexer, _release.PublishDate); - Subject.Add(_temporarilyRejected); + Subject.Add(_temporarilyRejected, PendingReleaseReason.Delay); VerifyNoInsert(); } + [Test] + public void should_not_add_if_it_is_the_same_release_from_the_same_indexer_twice() + { + GivenHeldRelease(_release.Title, _release.Indexer, _release.PublishDate, PendingReleaseReason.DownloadClientUnavailable); + GivenHeldRelease(_release.Title, _release.Indexer, _release.PublishDate, PendingReleaseReason.Fallback); + + Subject.Add(_temporarilyRejected, PendingReleaseReason.Delay); + + VerifyNoInsert(); + } + + [Test] + public void should_remove_duplicate_if_it_is_the_same_release_from_the_same_indexer_twice() + { + GivenHeldRelease(_release.Title, _release.Indexer, _release.PublishDate, PendingReleaseReason.DownloadClientUnavailable); + GivenHeldRelease(_release.Title, _release.Indexer, _release.PublishDate, PendingReleaseReason.Fallback); + + Subject.Add(_temporarilyRejected, PendingReleaseReason.Fallback); + + Mocker.GetMock() + .Verify(v => v.Delete(It.IsAny()), Times.Once()); + } + [Test] public void should_add_if_title_is_different() { GivenHeldRelease(_release.Title + "-RP", _release.Indexer, _release.PublishDate); - Subject.Add(_temporarilyRejected); + Subject.Add(_temporarilyRejected, PendingReleaseReason.Delay); VerifyInsert(); } @@ -132,7 +166,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 +176,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/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs index b70f24fdc..70ec02288 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using System.Linq; using FizzWare.NBuilder; using Marr.Data; using Moq; @@ -26,6 +27,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests private ReleaseInfo _release; private ParsedEpisodeInfo _parsedEpisodeInfo; private RemoteEpisode _remoteEpisode; + private List _heldReleases; [SetUp] public void Setup() @@ -52,25 +54,37 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests _release = Builder.CreateNew().Build(); - _parsedEpisodeInfo = Builder.CreateNew().Build(); - _parsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p); + _parsedEpisodeInfo = Builder.CreateNew() + .With(h => h.Quality = new QualityModel(Quality.HDTV720p)) + .With(h => h.AirDate = null) + .Build(); _remoteEpisode = new RemoteEpisode(); _remoteEpisode.Episodes = new List{ _episode }; _remoteEpisode.Series = _series; _remoteEpisode.ParsedEpisodeInfo = _parsedEpisodeInfo; _remoteEpisode.Release = _release; - + _temporarilyRejected = new DownloadDecision(_remoteEpisode, new Rejection("Temp Rejected", RejectionType.Temporary)); + _heldReleases = new List(); + Mocker.GetMock() .Setup(s => s.All()) - .Returns(new List()); + .Returns(_heldReleases); + + Mocker.GetMock() + .Setup(s => s.AllBySeriesId(It.IsAny())) + .Returns(i => _heldReleases.Where(v => v.SeriesId == i).ToList()); Mocker.GetMock() .Setup(s => s.GetSeries(It.IsAny())) .Returns(_series); + Mocker.GetMock() + .Setup(s => s.GetSeries(It.IsAny>())) + .Returns(new List { _series }); + Mocker.GetMock() .Setup(s => s.GetEpisodes(It.IsAny(), _series, true, null)) .Returns(new List {_episode}); @@ -92,9 +106,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests .With(h => h.ParsedEpisodeInfo = parsedEpisodeInfo) .Build(); - Mocker.GetMock() - .Setup(s => s.All()) - .Returns(heldReleases); + _heldReleases.AddRange(heldReleases); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs index 44c2a1029..d5c5058b1 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs @@ -38,6 +38,10 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests .Setup(s => s.GetSeries(It.IsAny())) .Returns(new Series()); + Mocker.GetMock() + .Setup(s => s.GetSeries(It.IsAny>())) + .Returns(new List { new Series() }); + Mocker.GetMock() .Setup(s => s.GetEpisodes(It.IsAny(), It.IsAny(), It.IsAny(), null)) .Returns(new List{ _episode }); @@ -63,7 +67,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests AssertRemoved(1); } - + [Test] public void should_remove_multiple_releases_release() { @@ -134,7 +138,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests AssertRemoved(2); } - + private void AssertRemoved(params int[] ids) { Mocker.GetMock().Verify(c => c.DeleteMany(It.Is>(s => s.SequenceEqual(ids)))); diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs index d62fb0d2b..0089af185 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using FizzWare.NBuilder; using Marr.Data; @@ -62,7 +62,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests _remoteEpisode.Series = _series; _remoteEpisode.ParsedEpisodeInfo = _parsedEpisodeInfo; _remoteEpisode.Release = _release; - + _temporarilyRejected = new DownloadDecision(_remoteEpisode, new Rejection("Temp Rejected", RejectionType.Temporary)); Mocker.GetMock() @@ -73,6 +73,10 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests .Setup(s => s.GetSeries(It.IsAny())) .Returns(_series); + Mocker.GetMock() + .Setup(s => s.GetSeries(It.IsAny>())) + .Returns(new List { _series }); + Mocker.GetMock() .Setup(s => s.GetEpisodes(It.IsAny(), _series, true, null)) .Returns(new List {_episode}); @@ -94,6 +98,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests .With(h => h.SeriesId = _series.Id) .With(h => h.Title = title) .With(h => h.Release = release) + .With(h => h.ParsedEpisodeInfo = new ParsedEpisodeInfo()) .Build(); Mocker.GetMock() diff --git a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs index 912b60335..0488e916e 100644 --- a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs @@ -104,7 +104,7 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads .Returns(remoteEpisode); Mocker.GetMock() - .Setup(s => s.ParseSpecialEpisodeTitle(It.IsAny(), It.IsAny(), It.IsAny(), null)) + .Setup(s => s.ParseSpecialEpisodeTitle(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null)) .Returns(remoteEpisode.ParsedEpisodeInfo); var client = new DownloadClientDefinition() diff --git a/src/NzbDrone.Core.Test/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs b/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs similarity index 97% rename from src/NzbDrone.Core.Test/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs rename to src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs index 6d4328b32..5e7a53ce3 100644 --- a/src/NzbDrone.Core.Test/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs +++ b/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs @@ -8,7 +8,7 @@ using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; using NzbDrone.Test.Common; -namespace NzbDrone.Core.Test.Metadata.Consumers.Roksbox +namespace NzbDrone.Core.Test.Extras.Metadata.Consumers.Roksbox { [TestFixture] public class FindMetadataFileFixture : CoreTest diff --git a/src/NzbDrone.Core.Test/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs b/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs similarity index 97% rename from src/NzbDrone.Core.Test/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs rename to src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs index 078744ec8..17f9d4f6e 100644 --- a/src/NzbDrone.Core.Test/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs +++ b/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs @@ -8,7 +8,7 @@ using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; using NzbDrone.Test.Common; -namespace NzbDrone.Core.Test.Metadata.Consumers.Wdtv +namespace NzbDrone.Core.Test.Extras.Metadata.Consumers.Wdtv { [TestFixture] public class FindMetadataFileFixture : CoreTest diff --git a/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Xbmc/FindMetadataFileFixture.cs b/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Xbmc/FindMetadataFileFixture.cs new file mode 100644 index 000000000..6f7043fec --- /dev/null +++ b/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Xbmc/FindMetadataFileFixture.cs @@ -0,0 +1,65 @@ +using System.IO; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Extras.Metadata; +using NzbDrone.Core.Extras.Metadata.Consumers.Xbmc; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Extras.Metadata.Consumers.Xbmc +{ + [TestFixture] + public class FindMetadataFileFixture : CoreTest + { + private Series _series; + + [SetUp] + public void Setup() + { + _series = Builder.CreateNew() + .With(s => s.Path = @"C:\Test\TV\The.Series".AsOsAgnostic()) + .Build(); + } + + [Test] + public void should_return_null_if_filename_is_not_handled() + { + var path = Path.Combine(_series.Path, "file.jpg"); + + Subject.FindMetadataFile(_series, path).Should().BeNull(); + } + + [Test] + public void should_return_metadata_for_xbmc_nfo() + { + var path = Path.Combine(_series.Path, "the.series.s01e01.episode.nfo"); + + Mocker.GetMock() + .Setup(v => v.IsXbmcNfoFile(path)) + .Returns(true); + + Subject.FindMetadataFile(_series, path).Type.Should().Be(MetadataType.EpisodeMetadata); + + Mocker.GetMock() + .Verify(v => v.IsXbmcNfoFile(It.IsAny()), Times.Once()); + } + + [Test] + public void should_return_null_for_scene_nfo() + { + var path = Path.Combine(_series.Path, "the.series.s01e01.episode.nfo"); + + Mocker.GetMock() + .Setup(v => v.IsXbmcNfoFile(path)) + .Returns(false); + + Subject.FindMetadataFile(_series, path).Should().BeNull(); + + Mocker.GetMock() + .Verify(v => v.IsXbmcNfoFile(It.IsAny()), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/Files/Indexers/TorrentRss/EvolutionWorld.xml b/src/NzbDrone.Core.Test/Files/Indexers/TorrentRss/EvolutionWorld.xml new file mode 100644 index 000000000..48c5b651b --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Indexers/TorrentRss/EvolutionWorld.xml @@ -0,0 +1,30 @@ + + + + Evolution World + Advanced RSS Feed for xbtitFM by Petr1fied + http://ew.pw + Tue, 15 Aug 2017 00:00:00 +0000 + (c) 2017 Evolution World + + + + <![CDATA[[TVShow --> TVShow Bluray 720p] Fargo S01 Complete Season 1 720p BRRip DD5.1 x264-PSYPHER [SEEDERS (3)/LEECHERS (0)]]]> + Fargo S01 Complete Season 1 720p BRRip DD5.1 x264-PSYPHER



Plot:

Various chronicles of deception, intrigue and murder in and around frozen Minnesota.

Note::-
Encode is tested and its perfect, no sync issue there in any episode.
All episodes comes with AC3 Audio for better audio and video plaback and English Subs are muxed in video .

TECHNiCAL Information:


RUNTIME..................: 1 hr x 10
Total SIZE...............: 9.75 GiB
Total Episodes...........: 10
VIDEO CODEC..............: x264 2nd Pass (High,L4.1)
RESOLUTION...............: 1280x720
BITRATE (Video)..........: 2100 Kbps - 2400 kbps
Aspect Ratio.............: 16:9
Video Container..........: MKV
FRAMERATE................: 23.967 fps
AUDIO....................: English AC3 6 Channel 384 kbps
SUBTITLES................: English, English (SDH)
CHAPTERS.................: Yes
SOURCE...................: DON (Thanks !)


GENRE...................: Crime | Drama | Thriller
RATING..................: 9.1/10 from 140,765 users
IMDB link...............: http://www.imdb.com/title/tt2802850/]]>
+ http://ew.pw/index.php?page=torrent-details&id=dea071a7a62a0d662538d46402fb112f30b8c9fa + http://ew.pw/index.php?page=torrent-details&id=dea071a7a62a0d662538d46402fb112f30b8c9fa + + Sun, 13 Aug 2017 22:21:43 +0000 +
+ + + <![CDATA[[TVShow --> TVShow Bluray 720p] American Horror Story S04 Complete Season 4 720p BRRip DD5.1 x264 - PSYPHER [SEEDERS (2)/LEECHERS (0)]]]> + + http://ew.pw/index.php?page=torrent-details&id=2725fe19ea2addf5aafbd523d134191b8abbb2ee + http://ew.pw/index.php?page=torrent-details&id=2725fe19ea2addf5aafbd523d134191b8abbb2ee + + Fri, 28 Jul 2017 16:29:51 +0000 + +
+
+ \ No newline at end of file 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/Files/Nzbs/NoFiles.nzb b/src/NzbDrone.Core.Test/Files/Nzbs/NoFiles.nzb new file mode 100644 index 000000000..8a38bcf83 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Nzbs/NoFiles.nzb @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/Nzbs/NotNzb.nzb b/src/NzbDrone.Core.Test/Files/Nzbs/NotNzb.nzb new file mode 100644 index 000000000..8ad464218 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Nzbs/NotNzb.nzb @@ -0,0 +1,102 @@ + + + + + alt.binaries.teevee + + + ZQ9h749E781168561i4J0Q6-01m6Q3185@2894t-767038L.Pg7769 + + + + + alt.binaries.teevee + + + 405Z5Y4066010l377VP1k6$U4873W933@f32Bs90575538201.pj54 + + + + + alt.binaries.teevee + + + 1x9894417$M.1s25279485O1s1Fi95Z1_18Z554u440@D1k0854_134551.0794144 + 48JYp$W18B2R1s2rI24EG7$907$r89875n60@8xK3374080716.115545M + 0U93471uI59Y781x77Q8-4286308-4aU35$07-179z@u90567568251.4zgUW968 + 5119x6417a.s06F$1k46$2q89298-C0@G7C-7811268.bK9x00B + B8$1_h0b64Z14-16_O6$ESw481L421n9agj7731k@414.473581-K$4.0Zd5A + O-4731$tn71v05623J9GT.yc22O975111dR01r58065p@Da1G9L33q74h3095.5X240 + d9R03J$07w75945Z556197z50F0w.0-5.x9$58311S@J0-v50033110.4a440EYJ + 05e650149.5r1Hk$E0Bko7G5B.1107mz8l17PS8F@vr816$S6T19245w.042B9 + 245Xy0w4o$tN6428321b.n1816Q1n95bE0816Y@q-qv7E12k.F3672H.16E19 + H681i185g64H23101kP125z41101O91P384l@E9n597k05j798D94X.2ezz1K + T18.6136787.HLJ806.8$Si49m0459445101Z15-5@b80M7.788598D.gXu201cR + Vdl8H243Go28j1o865772039416v2@090a20-v365N5S7qf.G225s6 + S9769892v956069345.0TN.i05R@I04825Gt2706N.BAj1DT1T + 041800q6F28q44365799m5CQ4D43895@1Bf6268z_Q20F.045JXl + 1c-e034z4l$9K45i44218ss25$X5_5R-1i76$40-71P@Xt691t8B686Fgv.VBSl + 76l441W.R146a5368ed02cp_44171410hT.l@Z98.k.70X9c.5mZ1w49 + 12D035G5745-KO43wZ9920ttr1338@V7d871S2-t04t8520.uQ18 + 59V4O77211HA1f5T8h1-53952zV-55294K4M04v@kS878H3g4z.B5561.L330519 + 44-yi1-79$751944J7094$y7-y49994440d86cSn@5C82v-1O9N.wk8wMb6 + oF7Wj3$Ydh7e030oD4.e81JM464O791495lJ@Pm058Qt4-G8Wv.T1i1a6O1 + 1T7_71M9d10F2.5953VP.11.4h75L@5049bBn384.14Ms + u8601765028G662749SD41j0m57651Zq70u1@J5281423406375.z.6PDSx57 + XY0476$R87Y16g2n45OO335541589V140R026j@y2q9296x7f23C.sqK71b9 + X7N3440l08B9T5940na4Ls397-T2.P5M12241525J57@r44O419p594M6G4I.d66RQ1 + p4148978k45.t88w2K9886H4223y5553T7$7p287TN@N8e1T98b_0.mo55a14G + 50U0a9iP07$A66010-51h55w386f@c$42$S96V57F5u0Y.6UDV35D + FnKN4n2749v958xa36J2570506414D293S@8H1A1X490$z3bv.ut6KQ4N + q4$d0$X8x6rm85m0Ewh307m255N@t2C7484zq870u.1RLndQ + 364U4342$5I242404oH90-1W3c0t16705057m650Cq9f@K32rE5297347130W.UNs8evbH + M3081U097-r06Y.yy9-1A538001B27f@L2834Y80c7b1075.Dy150 + 189585554.NS66E5D840N4Yq5m07NC1n@51L0393057L528n.k1Mc3j0S + 189048V505q89216C149I5f$53x-T@0V9i8n7o95.I.Z1lBJ5 + 5-L.555$139r45100-S23-59859@54844694q2.3EY9b + 641655313y0.Z002L0g39AZ11716U-uX015PI5.v6y@veS44H89Js91903K8.P3MAvk4k + 1C8f-yz-U-b20.610.0P1M-6Z5418i229160865010s1@M7l210D48Nc.nB0sPmi + 0653$L0.58749-1U_1PS95-1h9gQ145@0117y0-1x1p-h94.za18yc5 + 77-Mo3-a6514904987865.K0W710G4HB9237@501F7910J6j50-Bh.6cHx1 + m4I47082655rz$b7P751u9W679475F.89p@f.o.XZv5O7y.855rgXX + f075$y56E57d.t11787.0$6D155735M_w89-Y57q2@x0t5H91021wZ52Vh.1h7vabU + H7U1331Ad7718$Y69T-q3w4$l247HV49s985J@vi800i0004p.YD5oK + 9nr786955Ker.M583315CoJ1-W65a817-704@IN-wU12$M1E0g466.5sMJ3 + 0.3R9mN.n2_V086N0-4.Z5gAgZo@ey3G316U382o537.f51Ed5B + l106Z1-N411r7j44197l628r.b5Uwc55@k4-Cl_n5xc.1B.xZbNm + A91LT1X591x81.TI4130N$555A57q0@L70-p5qa50.40GB + V5$765JR6503w0-K63099R615736843G$Qj0ev@mz776wM86445N0.4I56ne + A86H2P415S689$568152-025O45V@s079644915.Dd57p0 + 31x5o36q14y9554L42882X0Q10e360Z64W4K9Onx38D@5g1509788414q.Y8wib + b$6795157EX1044V964e14-Y9E68614O94C@4061937876$f5.6.19tV + D00v8X$b80m93181273J-g076Qj2p79867v5d9689Rb2@r0592.v900.j43E050E + Tf78L4e535.o86PK0S.M2R3-66012814z@q-5j89Y29J214Y902.53Ra0f + 7i01.23411-lQW0212-Er260e9.N5e256jx243EX@91-T.15v40K5Hj.Fo1f + 3A$H7m63$i595.4713vv0A4$A7Lk7Jsq@0cM0Tw4107f.B520.q5Z91 + j572m$3h87LS$37167Wp10k41541.T779-Fn@V53C11045619xJ.52.0PnnX4v5 + A.2d4599a720rk2IB32h0X523MjTL415v89706-7Z45y@R4746-B106358.t3g62r4 + 5q6100961jM-G9F7t755x366zxc102M1SdMF@7394521p651X1I.AL05545a + 04e851111$12u2213-80VR133125B@7x8865M4hQ9$5.1N345x + K2476D3600-73B4W363$008s888980421f27125V$q0@0Zc0a56-m7550.1637vAr1 + 0306u425024v448ZeCE3Q9825m9th1858@5648018-H0.2k7J4.12k0B + 220u4SK433564Cr2l004t0wP888545779g@19j360863S$55559m.70V7Ndr + 5u1q051C5Qq8Z9Iy$Z.5.1510NY.S2565n@7m.5-09$z235p74.8kW5 + 6F472C8nh2621_X0C1093P7n39643b5p2f76s60r@1T55203qQY6.wZml1Vb + 5qC4568844767324-o8i05983-0f.n4.y.OBZ41f@q36B50684KU66.0R1784 + 4P0g470-F59307aDf.JF070Xx959648dO3y00463J6s@71P$D961$C0.11.I096sQ + z5kod75077z01w11-A5h.wiG550.J5-p756$81.Db@5l01K49h3K.Ok4R5512 + F3JX28.B8h90T0075-08001X5w611V071@D75X9263$6$9f.OT050p5Z + 2B8sT.A650z101514671183y47977219.M4211xYp@0b0021p736BX92.B0lSm4J3 + + + + + alt.binaries.teevee + + + 16ND-8I545Pq-s107t0h07g8908870711@K401476783.5.0mFs1 + iYdZ2D11089F310711.ci-O7O4KG03@260c03388O84Kd.GCEgv + r63cDD59Mg1c95738Sn75085O4X7823V1@16V6-b87O21S1937O.lw17o1VS + + + \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/Nzbs/ValidNzb.nzb b/src/NzbDrone.Core.Test/Files/Nzbs/ValidNzb.nzb new file mode 100644 index 000000000..138b0cd55 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Nzbs/ValidNzb.nzb @@ -0,0 +1,105 @@ + + + + + + alt.binaries.teevee + + + ZQ9h749E781168561i4J0Q6-01m6Q3185@2894t-767038L.Pg7769 + + + + + alt.binaries.teevee + + + 405Z5Y4066010l377VP1k6$U4873W933@f32Bs90575538201.pj54 + + + + + alt.binaries.teevee + + + 1x9894417$M.1s25279485O1s1Fi95Z1_18Z554u440@D1k0854_134551.0794144 + 48JYp$W18B2R1s2rI24EG7$907$r89875n60@8xK3374080716.115545M + 0U93471uI59Y781x77Q8-4286308-4aU35$07-179z@u90567568251.4zgUW968 + 5119x6417a.s06F$1k46$2q89298-C0@G7C-7811268.bK9x00B + B8$1_h0b64Z14-16_O6$ESw481L421n9agj7731k@414.473581-K$4.0Zd5A + O-4731$tn71v05623J9GT.yc22O975111dR01r58065p@Da1G9L33q74h3095.5X240 + d9R03J$07w75945Z556197z50F0w.0-5.x9$58311S@J0-v50033110.4a440EYJ + 05e650149.5r1Hk$E0Bko7G5B.1107mz8l17PS8F@vr816$S6T19245w.042B9 + 245Xy0w4o$tN6428321b.n1816Q1n95bE0816Y@q-qv7E12k.F3672H.16E19 + H681i185g64H23101kP125z41101O91P384l@E9n597k05j798D94X.2ezz1K + T18.6136787.HLJ806.8$Si49m0459445101Z15-5@b80M7.788598D.gXu201cR + Vdl8H243Go28j1o865772039416v2@090a20-v365N5S7qf.G225s6 + S9769892v956069345.0TN.i05R@I04825Gt2706N.BAj1DT1T + 041800q6F28q44365799m5CQ4D43895@1Bf6268z_Q20F.045JXl + 1c-e034z4l$9K45i44218ss25$X5_5R-1i76$40-71P@Xt691t8B686Fgv.VBSl + 76l441W.R146a5368ed02cp_44171410hT.l@Z98.k.70X9c.5mZ1w49 + 12D035G5745-KO43wZ9920ttr1338@V7d871S2-t04t8520.uQ18 + 59V4O77211HA1f5T8h1-53952zV-55294K4M04v@kS878H3g4z.B5561.L330519 + 44-yi1-79$751944J7094$y7-y49994440d86cSn@5C82v-1O9N.wk8wMb6 + oF7Wj3$Ydh7e030oD4.e81JM464O791495lJ@Pm058Qt4-G8Wv.T1i1a6O1 + 1T7_71M9d10F2.5953VP.11.4h75L@5049bBn384.14Ms + u8601765028G662749SD41j0m57651Zq70u1@J5281423406375.z.6PDSx57 + XY0476$R87Y16g2n45OO335541589V140R026j@y2q9296x7f23C.sqK71b9 + X7N3440l08B9T5940na4Ls397-T2.P5M12241525J57@r44O419p594M6G4I.d66RQ1 + p4148978k45.t88w2K9886H4223y5553T7$7p287TN@N8e1T98b_0.mo55a14G + 50U0a9iP07$A66010-51h55w386f@c$42$S96V57F5u0Y.6UDV35D + FnKN4n2749v958xa36J2570506414D293S@8H1A1X490$z3bv.ut6KQ4N + q4$d0$X8x6rm85m0Ewh307m255N@t2C7484zq870u.1RLndQ + 364U4342$5I242404oH90-1W3c0t16705057m650Cq9f@K32rE5297347130W.UNs8evbH + M3081U097-r06Y.yy9-1A538001B27f@L2834Y80c7b1075.Dy150 + 189585554.NS66E5D840N4Yq5m07NC1n@51L0393057L528n.k1Mc3j0S + 189048V505q89216C149I5f$53x-T@0V9i8n7o95.I.Z1lBJ5 + 5-L.555$139r45100-S23-59859@54844694q2.3EY9b + 641655313y0.Z002L0g39AZ11716U-uX015PI5.v6y@veS44H89Js91903K8.P3MAvk4k + 1C8f-yz-U-b20.610.0P1M-6Z5418i229160865010s1@M7l210D48Nc.nB0sPmi + 0653$L0.58749-1U_1PS95-1h9gQ145@0117y0-1x1p-h94.za18yc5 + 77-Mo3-a6514904987865.K0W710G4HB9237@501F7910J6j50-Bh.6cHx1 + m4I47082655rz$b7P751u9W679475F.89p@f.o.XZv5O7y.855rgXX + f075$y56E57d.t11787.0$6D155735M_w89-Y57q2@x0t5H91021wZ52Vh.1h7vabU + H7U1331Ad7718$Y69T-q3w4$l247HV49s985J@vi800i0004p.YD5oK + 9nr786955Ker.M583315CoJ1-W65a817-704@IN-wU12$M1E0g466.5sMJ3 + 0.3R9mN.n2_V086N0-4.Z5gAgZo@ey3G316U382o537.f51Ed5B + l106Z1-N411r7j44197l628r.b5Uwc55@k4-Cl_n5xc.1B.xZbNm + A91LT1X591x81.TI4130N$555A57q0@L70-p5qa50.40GB + V5$765JR6503w0-K63099R615736843G$Qj0ev@mz776wM86445N0.4I56ne + A86H2P415S689$568152-025O45V@s079644915.Dd57p0 + 31x5o36q14y9554L42882X0Q10e360Z64W4K9Onx38D@5g1509788414q.Y8wib + b$6795157EX1044V964e14-Y9E68614O94C@4061937876$f5.6.19tV + D00v8X$b80m93181273J-g076Qj2p79867v5d9689Rb2@r0592.v900.j43E050E + Tf78L4e535.o86PK0S.M2R3-66012814z@q-5j89Y29J214Y902.53Ra0f + 7i01.23411-lQW0212-Er260e9.N5e256jx243EX@91-T.15v40K5Hj.Fo1f + 3A$H7m63$i595.4713vv0A4$A7Lk7Jsq@0cM0Tw4107f.B520.q5Z91 + j572m$3h87LS$37167Wp10k41541.T779-Fn@V53C11045619xJ.52.0PnnX4v5 + A.2d4599a720rk2IB32h0X523MjTL415v89706-7Z45y@R4746-B106358.t3g62r4 + 5q6100961jM-G9F7t755x366zxc102M1SdMF@7394521p651X1I.AL05545a + 04e851111$12u2213-80VR133125B@7x8865M4hQ9$5.1N345x + K2476D3600-73B4W363$008s888980421f27125V$q0@0Zc0a56-m7550.1637vAr1 + 0306u425024v448ZeCE3Q9825m9th1858@5648018-H0.2k7J4.12k0B + 220u4SK433564Cr2l004t0wP888545779g@19j360863S$55559m.70V7Ndr + 5u1q051C5Qq8Z9Iy$Z.5.1510NY.S2565n@7m.5-09$z235p74.8kW5 + 6F472C8nh2621_X0C1093P7n39643b5p2f76s60r@1T55203qQY6.wZml1Vb + 5qC4568844767324-o8i05983-0f.n4.y.OBZ41f@q36B50684KU66.0R1784 + 4P0g470-F59307aDf.JF070Xx959648dO3y00463J6s@71P$D961$C0.11.I096sQ + z5kod75077z01w11-A5h.wiG550.J5-p756$81.Db@5l01K49h3K.Ok4R5512 + F3JX28.B8h90T0075-08001X5w611V071@D75X9263$6$9f.OT050p5Z + 2B8sT.A650z101514671183y47977219.M4211xYp@0b0021p736BX92.B0lSm4J3 + + + + + alt.binaries.teevee + + + 16ND-8I545Pq-s107t0h07g8908870711@K401476783.5.0mFs1 + iYdZ2D11089F310711.ci-O7O4KG03@260c03388O84Kd.GCEgv + r63cDD59Mg1c95738Sn75085O4X7823V1@16V6-b87O21S1937O.lw17o1VS + + + \ No newline at end of file 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/HealthCheck/Checks/MonoDebugFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/MonoDebugFixture.cs new file mode 100644 index 000000000..d0f629051 --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/MonoDebugFixture.cs @@ -0,0 +1,59 @@ +using NUnit.Framework; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; +using static NzbDrone.Core.HealthCheck.Checks.MonoDebugCheck; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class MonoDebugFixture : CoreTest + { + private void GivenHasStackFrame(bool hasStackFrame) + { + Mocker.GetMock() + .Setup(f => f.HasStackFrameInfo()) + .Returns(hasStackFrame); + } + + [Test] + public void should_return_ok_if_windows() + { + WindowsOnly(); + + Subject.Check().ShouldBeOk(); + } + + [Test] + public void should_return_ok_if_not_debug() + { + MonoOnly(); + + GivenHasStackFrame(false); + + Subject.Check().ShouldBeOk(); + } + + [Test] + public void should_log_warning_if_not_debug() + { + MonoOnly(); + + GivenHasStackFrame(false); + + Subject.Check(); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_return_ok_if_debug() + { + MonoOnly(); + + GivenHasStackFrame(true); + + Subject.Check().ShouldBeOk(); + } + } +} 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/Housekeeping/Housekeepers/FixFutureDownloadClientStatusTimesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureDownloadClientStatusTimesFixture.cs new file mode 100644 index 000000000..7ba5dbf22 --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureDownloadClientStatusTimesFixture.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.Housekeeping.Housekeepers; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class FixFutureDownloadClientStatusTimesFixture : CoreTest + { + [Test] + public void should_set_disabled_till_when_its_too_far_in_the_future() + { + var disabledTillTime = EscalationBackOff.Periods[1]; + var downloadClientStatuses = Builder.CreateListOfSize(5) + .All() + .With(t => t.DisabledTill = DateTime.UtcNow.AddDays(5)) + .With(t => t.InitialFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.MostRecentFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.EscalationLevel = 1) + .BuildListOfNew(); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(downloadClientStatuses); + + Subject.Clean(); + + Mocker.GetMock() + .Verify(v => v.UpdateMany( + It.Is>(i => i.All( + s => s.DisabledTill.Value <= DateTime.UtcNow.AddMinutes(disabledTillTime))) + ) + ); + } + + [Test] + public void should_set_initial_failure_when_its_in_the_future() + { + var downloadClientStatuses = Builder.CreateListOfSize(5) + .All() + .With(t => t.DisabledTill = DateTime.UtcNow.AddDays(-5)) + .With(t => t.InitialFailure = DateTime.UtcNow.AddDays(5)) + .With(t => t.MostRecentFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.EscalationLevel = 1) + .BuildListOfNew(); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(downloadClientStatuses); + + Subject.Clean(); + + Mocker.GetMock() + .Verify(v => v.UpdateMany( + It.Is>(i => i.All( + s => s.InitialFailure.Value <= DateTime.UtcNow)) + ) + ); + } + + [Test] + public void should_set_most_recent_failure_when_its_in_the_future() + { + var downloadClientStatuses = Builder.CreateListOfSize(5) + .All() + .With(t => t.DisabledTill = DateTime.UtcNow.AddDays(-5)) + .With(t => t.InitialFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.MostRecentFailure = DateTime.UtcNow.AddDays(5)) + .With(t => t.EscalationLevel = 1) + .BuildListOfNew(); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(downloadClientStatuses); + + Subject.Clean(); + + Mocker.GetMock() + .Verify(v => v.UpdateMany( + It.Is>(i => i.All( + s => s.MostRecentFailure.Value <= DateTime.UtcNow)) + ) + ); + } + + [Test] + public void should_not_change_statuses_when_times_are_in_the_past() + { + var downloadClientStatuses = Builder.CreateListOfSize(5) + .All() + .With(t => t.DisabledTill = DateTime.UtcNow.AddDays(-5)) + .With(t => t.InitialFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.MostRecentFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.EscalationLevel = 0) + .BuildListOfNew(); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(downloadClientStatuses); + + Subject.Clean(); + + Mocker.GetMock() + .Verify(v => v.UpdateMany( + It.Is>(i => i.Count == 0) + ) + ); + } + + + } +} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureIndexerStatusTimesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureIndexerStatusTimesFixture.cs new file mode 100644 index 000000000..56cc9cdac --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureIndexerStatusTimesFixture.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Housekeeping.Housekeepers; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class FixFutureIndexerStatusTimesFixture : CoreTest + { + [Test] + public void should_set_disabled_till_when_its_too_far_in_the_future() + { + var disabledTillTime = EscalationBackOff.Periods[1]; + var indexerStatuses = Builder.CreateListOfSize(5) + .All() + .With(t => t.DisabledTill = DateTime.UtcNow.AddDays(5)) + .With(t => t.InitialFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.MostRecentFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.EscalationLevel = 1) + .BuildListOfNew(); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(indexerStatuses); + + Subject.Clean(); + + Mocker.GetMock() + .Verify(v => v.UpdateMany( + It.Is>(i => i.All( + s => s.DisabledTill.Value < DateTime.UtcNow.AddMinutes(disabledTillTime))) + ) + ); + } + + [Test] + public void should_set_initial_failure_when_its_in_the_future() + { + var indexerStatuses = Builder.CreateListOfSize(5) + .All() + .With(t => t.DisabledTill = DateTime.UtcNow.AddDays(-5)) + .With(t => t.InitialFailure = DateTime.UtcNow.AddDays(5)) + .With(t => t.MostRecentFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.EscalationLevel = 1) + .BuildListOfNew(); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(indexerStatuses); + + Subject.Clean(); + + Mocker.GetMock() + .Verify(v => v.UpdateMany( + It.Is>(i => i.All( + s => s.InitialFailure.Value <= DateTime.UtcNow)) + ) + ); + } + + [Test] + public void should_set_most_recent_failure_when_its_in_the_future() + { + var indexerStatuses = Builder.CreateListOfSize(5) + .All() + .With(t => t.DisabledTill = DateTime.UtcNow.AddDays(-5)) + .With(t => t.InitialFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.MostRecentFailure = DateTime.UtcNow.AddDays(5)) + .With(t => t.EscalationLevel = 1) + .BuildListOfNew(); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(indexerStatuses); + + Subject.Clean(); + + Mocker.GetMock() + .Verify(v => v.UpdateMany( + It.Is>(i => i.All( + s => s.MostRecentFailure.Value <= DateTime.UtcNow)) + ) + ); + } + + [Test] + public void should_not_change_statuses_when_times_are_in_the_past() + { + var indexerStatuses = Builder.CreateListOfSize(5) + .All() + .With(t => t.DisabledTill = DateTime.UtcNow.AddDays(-5)) + .With(t => t.InitialFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.MostRecentFailure = DateTime.UtcNow.AddDays(-5)) + .With(t => t.EscalationLevel = 0) + .BuildListOfNew(); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(indexerStatuses); + + Subject.Clean(); + + Mocker.GetMock() + .Verify(v => v.UpdateMany( + It.Is>(i => i.Count == 0) + ) + ); + } + + + } +} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureRunScheduledTasksFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureRunScheduledTasksFixture.cs deleted file mode 100644 index 4235b217e..000000000 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureRunScheduledTasksFixture.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using FizzWare.NBuilder; -using FluentAssertions; -using Microsoft.Practices.ObjectBuilder2; -using NUnit.Framework; -using NzbDrone.Core.Housekeeping.Housekeepers; -using NzbDrone.Core.Jobs; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.Housekeeping.Housekeepers -{ - [TestFixture] - public class FixFutureRunScheduledTasksFixture : DbTest - { - [Test] - public void should_set_last_execution_time_to_now_when_its_in_the_future() - { - var tasks = Builder.CreateListOfSize(5) - .All() - .With(t => t.LastExecution = DateTime.UtcNow.AddDays(5)) - .BuildListOfNew(); - - Db.InsertMany(tasks); - - Subject.Clean(); - - AllStoredModels.ForEach(t => t.LastExecution.Should().BeBefore(DateTime.UtcNow)); - } - - [Test] - public void should_not_change_last_execution_time_when_its_in_the_past() - { - var expectedTime = DateTime.UtcNow.AddHours(-1); - - var tasks = Builder.CreateListOfSize(5) - .All() - .With(t => t.LastExecution = expectedTime) - .BuildListOfNew(); - - Db.InsertMany(tasks); - - Subject.Clean(); - - AllStoredModels.ForEach(t => t.LastExecution.Should().Be(expectedTime)); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/NzbSearchServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/NzbSearchServiceFixture.cs index f6089004e..e9aa1cf13 100644 --- a/src/NzbDrone.Core.Test/IndexerSearchTests/NzbSearchServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerSearchTests/NzbSearchServiceFixture.cs @@ -1,16 +1,17 @@ -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.DataAugmentation.Scene; -using NzbDrone.Core.IndexerSearch; -using NzbDrone.Core.Test.Framework; -using FizzWare.NBuilder; +using System; using System.Collections.Generic; using System.Linq; +using FizzWare.NBuilder; using FluentAssertions; using Moq; using NUnit.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.DataAugmentation.Scene; +using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Indexers; +using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.Test.IndexerSearchTests { @@ -55,7 +56,7 @@ namespace NzbDrone.Core.Test.IndexerSearchTests .Returns(new List()); } - private void WithEpisode(int seasonNumber, int episodeNumber, int? sceneSeasonNumber, int? sceneEpisodeNumber) + private void WithEpisode(int seasonNumber, int episodeNumber, int? sceneSeasonNumber, int? sceneEpisodeNumber, string airDate = null) { var episode = Builder.CreateNew() .With(v => v.SeriesId == _xemSeries.Id) @@ -64,6 +65,7 @@ namespace NzbDrone.Core.Test.IndexerSearchTests .With(v => v.EpisodeNumber, episodeNumber) .With(v => v.SceneSeasonNumber, sceneSeasonNumber) .With(v => v.SceneEpisodeNumber, sceneEpisodeNumber) + .With(v => v.AirDate = (airDate ?? $"{2000 + seasonNumber}-{episodeNumber:00}-05")) .With(v => v.Monitored = true) .Build(); @@ -108,10 +110,22 @@ namespace NzbDrone.Core.Test.IndexerSearchTests .Callback(s => result.Add(s)) .Returns(new List()); + _mockIndexer.Setup(v => v.Fetch(It.IsAny())) + .Callback(s => result.Add(s)) + .Returns(new List()); + + _mockIndexer.Setup(v => v.Fetch(It.IsAny())) + .Callback(s => result.Add(s)) + .Returns(new List()); + _mockIndexer.Setup(v => v.Fetch(It.IsAny())) .Callback(s => result.Add(s)) .Returns(new List()); + _mockIndexer.Setup(v => v.Fetch(It.IsAny())) + .Callback(s => result.Add(s)) + .Returns(new List()); + return result; } @@ -249,6 +263,68 @@ namespace NzbDrone.Core.Test.IndexerSearchTests criteria.Count.Should().Be(0); } + [Test] + public void season_search_for_daily_should_search_multiple_years() + { + WithEpisode(1, 1, null, null, "2005-12-30"); + WithEpisode(1, 2, null, null, "2005-12-31"); + WithEpisode(1, 3, null, null, "2006-01-01"); + WithEpisode(1, 4, null, null, "2006-01-02"); + _xemSeries.SeriesType = SeriesTypes.Daily; + + var allCriteria = WatchForSearchCriteria(); + + Subject.SeasonSearch(_xemSeries.Id, 1, false, true); + + var criteria = allCriteria.OfType().ToList(); + + criteria.Count.Should().Be(2); + criteria[0].Year.Should().Be(2005); + criteria[1].Year.Should().Be(2006); + } + + [Test] + public void season_search_for_daily_should_search_single_episode_if_possible() + { + WithEpisode(1, 1, null, null, "2005-12-30"); + WithEpisode(1, 2, null, null, "2005-12-31"); + WithEpisode(1, 3, null, null, "2006-01-01"); + _xemSeries.SeriesType = SeriesTypes.Daily; + + var allCriteria = WatchForSearchCriteria(); + + Subject.SeasonSearch(_xemSeries.Id, 1, false, true); + + var criteria1 = allCriteria.OfType().ToList(); + var criteria2 = allCriteria.OfType().ToList(); + + criteria1.Count.Should().Be(1); + criteria1[0].Year.Should().Be(2005); + + criteria2.Count.Should().Be(1); + criteria2[0].AirDate.Should().Be(new DateTime(2006, 1, 1)); + } + + [Test] + public void season_search_for_daily_should_not_search_for_unmonitored_episodes() + { + WithEpisode(1, 1, null, null, "2005-12-30"); + WithEpisode(1, 2, null, null, "2005-12-31"); + WithEpisode(1, 3, null, null, "2006-01-01"); + _xemSeries.SeriesType = SeriesTypes.Daily; + _xemEpisodes[0].Monitored = false; + + var allCriteria = WatchForSearchCriteria(); + + Subject.SeasonSearch(_xemSeries.Id, 1, false, true); + + var criteria1 = allCriteria.OfType().ToList(); + var criteria2 = allCriteria.OfType().ToList(); + + criteria1.Should().HaveCount(0); + criteria2.Should().HaveCount(2); + } + [Test] public void getscenenames_should_use_seasonnumber_if_no_scene_seasonnumber_is_available() { diff --git a/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs index b2819434d..ce3abe40e 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs @@ -30,6 +30,7 @@ namespace NzbDrone.Core.Test.IndexerTests [TestCase("100 Kb/s")] [TestCase(" 12341234")] [TestCase("12341234 other")] + [TestCase("")] public void should_not_parse_size(string sizeString) { var result = RssParser.ParseSize(sizeString, true); 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/SeedConfigProviderFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/SeedConfigProviderFixture.cs new file mode 100644 index 000000000..9a29e5193 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/SeedConfigProviderFixture.cs @@ -0,0 +1,65 @@ +using System; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Torznab; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.IndexerTests +{ + [TestFixture] + public class SeedConfigProviderFixture : CoreTest + { + [Test] + public void should_not_return_config_for_non_existent_indexer() + { + Mocker.GetMock() + .Setup(v => v.Get(It.IsAny())) + .Throws(new ModelNotFoundException(typeof(IndexerDefinition), 0)); + + var result = Subject.GetSeedConfiguration(new RemoteEpisode + { + Release = new ReleaseInfo() + { + DownloadProtocol = DownloadProtocol.Torrent, + IndexerId = 0 + } + }); + + result.Should().BeNull(); + } + + [Test] + public void should_return_season_time_for_season_packs() + { + var settings = new TorznabSettings(); + settings.SeedCriteria.SeasonPackSeedTime = 10; + + Mocker.GetMock() + .Setup(v => v.Get(It.IsAny())) + .Returns(new IndexerDefinition + { + Settings = settings + }); + + var result = Subject.GetSeedConfiguration(new RemoteEpisode + { + Release = new ReleaseInfo() + { + DownloadProtocol = DownloadProtocol.Torrent, + IndexerId = 1 + }, + ParsedEpisodeInfo = new ParsedEpisodeInfo + { + FullSeason = true + } + }); + + result.Should().NotBeNull(); + result.SeedTime.Should().Be(TimeSpan.FromMinutes(10)); + } + } +} 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..0a384c099 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,45 @@ 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_parse_recent_feed_from_EveolutionWorld_without_size() + { + Subject.Definition.Settings.As().AllowZeroSize = true; + GivenRecentFeedResponse("TorrentRss/EvolutionWorld.xml"); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(2); + releases.First().Should().BeOfType(); + + var torrentInfo = releases.First() as TorrentInfo; + + torrentInfo.Title.Should().Be("[TVShow --> TVShow Bluray 720p] Fargo S01 Complete Season 1 720p BRRip DD5.1 x264-PSYPHER [SEEDERS (3)/LEECHERS (0)]"); + torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + torrentInfo.DownloadUrl.Should().Be("http://ew.pw/download.php?id=dea071a7a62a0d662538d46402fb112f30b8c9fa&f=Fargo%20S01%20Complete%20Season%201%20720p%20BRRip%20DD5.1%20x264-PSYPHER.torrent&auth=secret"); + torrentInfo.InfoUrl.Should().BeNullOrEmpty(); + torrentInfo.CommentUrl.Should().BeNullOrEmpty(); + torrentInfo.Indexer.Should().Be(Subject.Definition.Name); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("2017-08-13T22:21:43Z").ToUniversalTime()); + torrentInfo.Size.Should().Be(0); + torrentInfo.InfoHash.Should().BeNull(); + torrentInfo.MagnetUrl.Should().BeNull(); + torrentInfo.Peers.Should().NotHaveValue(); + torrentInfo.Seeders.Should().NotHaveValue(); + } + + [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/DiskScanServiceTests/ScanFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs index 86ab5351e..ee77f0df0 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; using FizzWare.NBuilder; @@ -269,6 +269,22 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _series), Times.Once()); } + [Test] + public void should_scan_files_that_start_with_period() + { + GivenSeriesFolder(); + + GivenFiles(new List + { + Path.Combine(_series.Path, "Season 1", ".s01e01.mkv").AsOsAgnostic() + }); + + Subject.Scan(_series); + + Mocker.GetMock() + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _series), Times.Once()); + } + [Test] public void should_not_scan_subfolder_of_season_folder_that_starts_with_a_period() { 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/Aggregation/Aggregators/AggregateEpisodesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateEpisodesFixture.cs new file mode 100644 index 000000000..45d24727a --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateEpisodesFixture.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Aggregation.Aggregators +{ + [TestFixture] + public class AugmentEpisodesFixture : CoreTest + { + private Series _series; + + [SetUp] + public void Setup() + { + _series = Builder.CreateNew().Build(); + + var augmenters = new List> + { + new Mock() + }; + + Mocker.SetConstant(augmenters.Select(c => c.Object)); + } + + [Test] + public void should_not_use_folder_for_full_season() + { + var fileEpisodeInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); + var folderEpisodeInfo = Parser.Parser.ParseTitle("Series.Title.S01"); + var localEpisode = new LocalEpisode + { + FileEpisodeInfo = fileEpisodeInfo, + FolderEpisodeInfo = folderEpisodeInfo, + Path = @"C:\Test\Unsorted TV\Series.Title.S01\Series.Title.S01E01.mkv".AsOsAgnostic(), + Series = _series + }; + + Subject.Aggregate(localEpisode, false); + + Mocker.GetMock() + .Verify(v => v.GetEpisodes(fileEpisodeInfo, _series, localEpisode.SceneSource, null), Times.Once()); + } + + [Test] + public void should_not_use_folder_when_it_contains_more_than_one_valid_video_file() + { + var fileEpisodeInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); + var folderEpisodeInfo = Parser.Parser.ParseTitle("Series.Title.S01"); + var localEpisode = new LocalEpisode + { + FileEpisodeInfo = fileEpisodeInfo, + FolderEpisodeInfo = folderEpisodeInfo, + Path = @"C:\Test\Unsorted TV\Series.Title.S01\Series.Title.S01E01.mkv".AsOsAgnostic(), + Series = _series + }; + + Subject.Aggregate(localEpisode, true); + + Mocker.GetMock() + .Verify(v => v.GetEpisodes(fileEpisodeInfo, _series, localEpisode.SceneSource, null), Times.Once()); + } + + [Test] + public void should_not_use_folder_name_if_file_name_is_scene_name() + { + var fileEpisodeInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); + var folderEpisodeInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); + var localEpisode = new LocalEpisode + { + FileEpisodeInfo = fileEpisodeInfo, + FolderEpisodeInfo = folderEpisodeInfo, + Path = @"C:\Test\Unsorted TV\Series.Title.S01E01\Series.Title.S01E01.720p.HDTV-Sonarr.mkv".AsOsAgnostic(), + Series = _series + }; + + Subject.Aggregate(localEpisode, false); + + Mocker.GetMock() + .Verify(v => v.GetEpisodes(fileEpisodeInfo, _series, localEpisode.SceneSource, null), Times.Once()); + } + + [Test] + public void should_use_folder_when_only_one_video_file() + { + var fileEpisodeInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); + var folderEpisodeInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); + var localEpisode = new LocalEpisode + { + FileEpisodeInfo = fileEpisodeInfo, + FolderEpisodeInfo = folderEpisodeInfo, + Path = @"C:\Test\Unsorted TV\Series.Title.S01E01\Series.Title.S01E01.mkv".AsOsAgnostic(), + Series = _series + }; + + Subject.Aggregate(localEpisode, false); + + Mocker.GetMock() + .Verify(v => v.GetEpisodes(folderEpisodeInfo, _series, localEpisode.SceneSource, null), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateQualityFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateQualityFixture.cs new file mode 100644 index 000000000..3b39c8526 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateQualityFixture.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators.Augmenters.Quality; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Aggregation.Aggregators +{ + [TestFixture] + public class AugmentQualityFixture : CoreTest + { + private Mock _mediaInfoAugmenter; + private Mock _fileExtensionAugmenter; + private Mock _nameAugmenter; + + private IEnumerable _qualityAugmenters; + + [SetUp] + public void Setup() + { + _mediaInfoAugmenter = new Mock(); + _fileExtensionAugmenter = new Mock(); + _nameAugmenter = new Mock(); + + _mediaInfoAugmenter.Setup(s => s.AugmentQuality(It.IsAny())) + .Returns(AugmentQualityResult.ResolutionOnly(1080, Confidence.MediaInfo)); + + _fileExtensionAugmenter.Setup(s => s.AugmentQuality(It.IsAny())) + .Returns(new AugmentQualityResult(QualitySource.Television, Confidence.Fallback, 720, Confidence.Fallback, new Revision())); + + _nameAugmenter.Setup(s => s.AugmentQuality(It.IsAny())) + .Returns(new AugmentQualityResult(QualitySource.Television, Confidence.Default, 480, Confidence.Default, new Revision())); + } + + private void GivenAugmenters(params Mock[] mocks) + { + Mocker.SetConstant>(mocks.Select(c => c.Object)); + } + + [Test] + public void should_return_HDTV720_from_extension_when_other_augments_are_null() + { + var nullMock = new Mock(); + nullMock.Setup(s => s.AugmentQuality(It.IsAny())) + .Returns(l => null); + + GivenAugmenters(_fileExtensionAugmenter, nullMock); + + var result = Subject.Aggregate(new LocalEpisode(), false); + + result.Quality.QualityDetectionSource.Should().Be(QualityDetectionSource.Extension); + result.Quality.Quality.Should().Be(Quality.HDTV720p); + } + + [Test] + public void should_return_SDTV_when_HDTV720_came_from_extension() + { + GivenAugmenters(_fileExtensionAugmenter, _nameAugmenter); + + var result = Subject.Aggregate(new LocalEpisode(), false); + + result.Quality.QualityDetectionSource.Should().Be(QualityDetectionSource.Name); + result.Quality.Quality.Should().Be(Quality.SDTV); + } + + [Test] + public void should_return_HDTV1080p_when_HDTV720_came_from_extension_and_mediainfo_indicates_1080() + { + GivenAugmenters(_fileExtensionAugmenter, _mediaInfoAugmenter); + + var result = Subject.Aggregate(new LocalEpisode(), false); + + result.Quality.QualityDetectionSource.Should().Be(QualityDetectionSource.MediaInfo); + result.Quality.Quality.Should().Be(Quality.HDTV1080p); + } + + [Test] + public void should_return_HDTV1080p_when_SDTV_came_from_name_and_mediainfo_indicates_1080() + { + GivenAugmenters(_nameAugmenter, _mediaInfoAugmenter); + + var result = Subject.Aggregate(new LocalEpisode(), false); + + result.Quality.QualityDetectionSource.Should().Be(QualityDetectionSource.MediaInfo); + result.Quality.Quality.Should().Be(Quality.HDTV1080p); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromMediaInfoFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromMediaInfoFixture.cs new file mode 100644 index 000000000..eabd186ac --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromMediaInfoFixture.cs @@ -0,0 +1,68 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators.Augmenters.Quality; +using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Aggregation.Aggregators.Augmenters.Quality +{ + [TestFixture] + public class AugmentQualityFromMediaInfoFixture : CoreTest + { + [Test] + public void should_return_null_if_media_info_is_null() + { + var localEpisode = Builder.CreateNew() + .With(l => l.MediaInfo = null) + .Build(); + + Subject.AugmentQuality(localEpisode).Should().Be(null); + } + + [Test] + public void should_return_null_if_media_info_width_is_zero() + { + var mediaInfo = Builder.CreateNew() + .With(m => m.Width = 0) + .Build(); + + var localEpisode = Builder.CreateNew() + .With(l => l.MediaInfo = mediaInfo) + .Build(); + + Subject.AugmentQuality(localEpisode).Should().Be(null); + } + + [TestCase(4096, 2160)] // True 4K + [TestCase(4000, 2160)] + [TestCase(3840, 2160)] // 4K UHD + [TestCase(3200, 2160)] + [TestCase(2000, 1080)] + [TestCase(1920, 1080)] // Full HD + [TestCase(1800, 1080)] + [TestCase(1490, 720)] + [TestCase(1280, 720)] // HD + [TestCase(1200, 720)] + [TestCase(800, 480)] + [TestCase(720, 480)] // SDTV + [TestCase(600, 480)] + [TestCase(100, 480)] + public void should_return_closest_resolution(int mediaInfoWidth, int expectedResolution) + { + var mediaInfo = Builder.CreateNew() + .With(m => m.Width = mediaInfoWidth) + .Build(); + + var localEpisode = Builder.CreateNew() + .With(l => l.MediaInfo = mediaInfo) + .Build(); + + var result = Subject.AugmentQuality(localEpisode); + + result.Should().NotBe(null); + result.Resolution.Should().Be(expectedResolution); + } + } +} 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..416311940 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs @@ -6,7 +6,6 @@ using NUnit.Framework; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.EpisodeImport; -using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; @@ -14,6 +13,8 @@ using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; using NzbDrone.Test.Common; using FizzWare.NBuilder; +using NzbDrone.Core.Download; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation; namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport { @@ -44,32 +45,29 @@ 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.Path = @"C:\Test\Series".AsOsAgnostic()) .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) .Build(); _quality = new QualityModel(Quality.DVD); _localEpisode = new LocalEpisode - { + { Series = _series, Quality = _quality, Episodes = new List { new Episode() }, Path = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi" }; - Mocker.GetMock() - .Setup(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(_localEpisode); - GivenVideoFiles(new List { @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi".AsOsAgnostic() }); } @@ -87,19 +85,31 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport .Returns(_videoFiles); } + private void GivenAugmentationSuccess() + { + Mocker.GetMock() + .Setup(s => s.Augment(It.IsAny(), It.IsAny())) + .Callback((localEpisode, otherFiles) => + { + localEpisode.Episodes = _localEpisode.Episodes; + }); + } + [Test] public void should_call_all_specifications() { + var downloadClientItem = Builder.CreateNew().Build(); + GivenAugmentationSuccess(); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - Subject.GetImportDecisions(_videoFiles, new Series(), null, false); + Subject.GetImportDecisions(_videoFiles, _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(It.IsAny(), downloadClientItem), Times.Once()); + _fail2.Verify(c => c.IsSatisfiedBy(It.IsAny(), downloadClientItem), Times.Once()); + _fail3.Verify(c => c.IsSatisfiedBy(It.IsAny(), downloadClientItem), Times.Once()); + _pass1.Verify(c => c.IsSatisfiedBy(It.IsAny(), downloadClientItem), Times.Once()); + _pass2.Verify(c => c.IsSatisfiedBy(It.IsAny(), downloadClientItem), Times.Once()); + _pass3.Verify(c => c.IsSatisfiedBy(It.IsAny(), downloadClientItem), Times.Once()); } [Test] @@ -107,7 +117,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport { GivenSpecifications(_fail1); - var result = Subject.GetImportDecisions(_videoFiles, new Series()); + var result = Subject.GetImportDecisions(_videoFiles, _series); result.Single().Approved.Should().BeFalse(); } @@ -117,17 +127,18 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport { GivenSpecifications(_pass1, _fail1, _pass2, _pass3); - var result = Subject.GetImportDecisions(_videoFiles, new Series()); + var result = Subject.GetImportDecisions(_videoFiles, _series); result.Single().Approved.Should().BeFalse(); } [Test] - public void should_return_pass_if_all_specs_pass() + public void should_return_approved_if_all_specs_pass() { + GivenAugmentationSuccess(); GivenSpecifications(_pass1, _pass2, _pass3); - var result = Subject.GetImportDecisions(_videoFiles, new Series()); + var result = Subject.GetImportDecisions(_videoFiles, _series); result.Single().Approved.Should().BeTrue(); } @@ -135,9 +146,10 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport [Test] public void should_have_same_number_of_rejections_as_specs_that_failed() { + GivenAugmentationSuccess(); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - var result = Subject.GetImportDecisions(_videoFiles, new Series()); + var result = Subject.GetImportDecisions(_videoFiles, _series); result.Single().Rejections.Should().HaveCount(3); } @@ -146,8 +158,8 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport { GivenSpecifications(_pass1); - Mocker.GetMock() - .Setup(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(c => c.Augment(It.IsAny(), It.IsAny())) .Throws(); _videoFiles = new List @@ -161,76 +173,17 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport Subject.GetImportDecisions(_videoFiles, _series); - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(_videoFiles.Count)); + Mocker.GetMock() + .Verify(c => c.Augment(It.IsAny(), It.IsAny()), Times.Exactly(_videoFiles.Count)); ExceptionVerification.ExpectedErrors(3); } - [Test] - public void should_use_file_quality_if_folder_quality_is_null() - { - GivenSpecifications(_pass1, _pass2, _pass3); - var expectedQuality = QualityParser.ParseQuality(_videoFiles.Single()); - - var result = Subject.GetImportDecisions(_videoFiles, _series); - - result.Single().LocalEpisode.Quality.Should().Be(expectedQuality); - } - - [Test] - public void should_use_file_quality_if_file_quality_was_determined_by_name() - { - GivenSpecifications(_pass1, _pass2, _pass3); - var expectedQuality = QualityParser.ParseQuality(_videoFiles.Single()); - - var result = Subject.GetImportDecisions(_videoFiles, _series, new ParsedEpisodeInfo{Quality = new QualityModel(Quality.SDTV)}, true); - - result.Single().LocalEpisode.Quality.Should().Be(expectedQuality); - } - - [Test] - public void should_use_folder_quality_when_file_quality_was_determined_by_the_extension() - { - GivenSpecifications(_pass1, _pass2, _pass3); - GivenVideoFiles(new string[] { @"C:\Test\Unsorted\The.Office.S03E115.mkv".AsOsAgnostic() }); - - _localEpisode.Path = _videoFiles.Single(); - _localEpisode.Quality.QualitySource = QualitySource.Extension; - _localEpisode.Quality.Quality = Quality.HDTV720p; - - var expectedQuality = new QualityModel(Quality.SDTV); - - var result = Subject.GetImportDecisions(_videoFiles, _series, new ParsedEpisodeInfo { Quality = expectedQuality }, true); - - result.Single().LocalEpisode.Quality.Should().Be(expectedQuality); - } - - [Test] - public void should_use_folder_quality_when_greater_than_file_quality() - { - GivenSpecifications(_pass1, _pass2, _pass3); - GivenVideoFiles(new string[] { @"C:\Test\Unsorted\The.Office.S03E115.mkv".AsOsAgnostic() }); - - _localEpisode.Path = _videoFiles.Single(); - _localEpisode.Quality.Quality = Quality.HDTV720p; - - var expectedQuality = new QualityModel(Quality.Bluray720p); - - var result = Subject.GetImportDecisions(_videoFiles, _series, new ParsedEpisodeInfo { Quality = expectedQuality }, true); - - result.Single().LocalEpisode.Quality.Should().Be(expectedQuality); - } - [Test] public void should_not_throw_if_episodes_are_not_found() { GivenSpecifications(_pass1); - Mocker.GetMock() - .Setup(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new LocalEpisode() { Path = "test" }); - _videoFiles = new List { "The.Office.S03E115.DVDRip.XviD-OSiTV", @@ -242,154 +195,18 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport var decisions = Subject.GetImportDecisions(_videoFiles, _series); - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(_videoFiles.Count)); + Mocker.GetMock() + .Verify(c => c.Augment(It.IsAny(), It.IsAny()), Times.Exactly(_videoFiles.Count)); decisions.Should().HaveCount(3); decisions.First().Rejections.Should().NotBeEmpty(); } - [Test] - public void should_not_use_folder_for_full_season() - { - var videoFiles = new[] - { - @"C:\Test\Unsorted\Series.Title.S01\S01E01.mkv".AsOsAgnostic(), - @"C:\Test\Unsorted\Series.Title.S01\S01E02.mkv".AsOsAgnostic(), - @"C:\Test\Unsorted\Series.Title.S01\S01E03.mkv".AsOsAgnostic() - }; - - GivenSpecifications(_pass1); - GivenVideoFiles(videoFiles); - - var folderInfo = Parser.Parser.ParseTitle("Series.Title.S01"); - - Subject.GetImportDecisions(_videoFiles, _series, folderInfo, true); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), null, true), Times.Exactly(3)); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.Is(p => p != null), true), Times.Never()); - } - - [Test] - public void should_not_use_folder_when_it_contains_more_than_one_valid_video_file() - { - var videoFiles = new[] - { - @"C:\Test\Unsorted\Series.Title.S01E01\S01E01.mkv".AsOsAgnostic(), - @"C:\Test\Unsorted\Series.Title.S01E01\1x01.mkv".AsOsAgnostic() - }; - - GivenSpecifications(_pass1); - GivenVideoFiles(videoFiles); - - var folderInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); - - Subject.GetImportDecisions(_videoFiles, _series, folderInfo, true); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), null, true), Times.Exactly(2)); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.Is(p => p != null), true), Times.Never()); - } - - [Test] - public void should_use_folder_when_only_one_video_file() - { - var videoFiles = new[] - { - @"C:\Test\Unsorted\Series.Title.S01E01\S01E01.mkv".AsOsAgnostic() - }; - - GivenSpecifications(_pass1); - GivenVideoFiles(videoFiles); - - var folderInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); - - Subject.GetImportDecisions(_videoFiles, _series, folderInfo, true); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.IsAny(), true), Times.Exactly(1)); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), null, true), Times.Never()); - } - - [Test] - public void should_use_folder_when_only_one_video_file_and_a_sample() - { - var videoFiles = new[] - { - @"C:\Test\Unsorted\Series.Title.S01E01\S01E01.mkv".AsOsAgnostic(), - @"C:\Test\Unsorted\Series.Title.S01E01\S01E01.sample.mkv".AsOsAgnostic() - }; - - GivenSpecifications(_pass1); - GivenVideoFiles(videoFiles.ToList()); - - Mocker.GetMock() - .Setup(s => s.IsSample(_series, It.IsAny(), It.Is(c => c.Contains("sample")), It.IsAny(), It.IsAny())) - .Returns(true); - - var folderInfo = Parser.Parser.ParseTitle("Series.Title.S01E01"); - - Subject.GetImportDecisions(_videoFiles, _series, folderInfo, true); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.IsAny(), true), Times.Exactly(2)); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), null, true), Times.Never()); - } - - [Test] - public void should_not_use_folder_name_if_file_name_is_scene_name() - { - var videoFiles = new[] - { - @"C:\Test\Unsorted\Series.Title.S01E01.720p.HDTV-LOL\Series.Title.S01E01.720p.HDTV-LOL.mkv".AsOsAgnostic() - }; - - GivenSpecifications(_pass1); - GivenVideoFiles(videoFiles); - - var folderInfo = Parser.Parser.ParseTitle("Series.Title.S01E01.720p.HDTV-LOL"); - - Subject.GetImportDecisions(_videoFiles, _series, folderInfo, true); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), null, true), Times.Exactly(1)); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.Is(p => p != null), true), Times.Never()); - } - - [Test] - public void should_not_use_folder_quality_when_it_is_unknown() - { - GivenSpecifications(_pass1, _pass2, _pass3); - - _series.Profile = new Profile - { - Items = Qualities.QualityFixture.GetDefaultQualities(Quality.DVD, Quality.Unknown) - }; - - - var folderQuality = new QualityModel(Quality.Unknown); - - var result = Subject.GetImportDecisions(_videoFiles, _series, new ParsedEpisodeInfo { Quality = folderQuality}, true); - - result.Single().LocalEpisode.Quality.Should().Be(_quality); - } - [Test] public void should_return_a_decision_when_exception_is_caught() { - Mocker.GetMock() - .Setup(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(c => c.Augment(It.IsAny(), It.IsAny())) .Throws(); _videoFiles = new List diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/EpisodeTitleSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/EpisodeTitleSpecificationFixture.cs new file mode 100644 index 000000000..58e50fc7c --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/EpisodeTitleSpecificationFixture.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications +{ + [TestFixture] + public class EpisodeTitleSpecificationFixture : CoreTest + { + private Series _series; + private LocalEpisode _localEpisode; + + [SetUp] + public void Setup() + { + _series = Builder.CreateNew() + .With(s => s.SeriesType = SeriesTypes.Standard) + .With(s => s.Path = @"C:\Test\TV\30 Rock".AsOsAgnostic()) + .Build(); + + var episodes = Builder.CreateListOfSize(1) + .All() + .With(e => e.SeasonNumber = 1) + .With(e => e.AirDateUtc = DateTime.UtcNow) + .Build() + .ToList(); + + _localEpisode = new LocalEpisode + { + Path = @"C:\Test\Unsorted\30 Rock\30.rock.s01e01.avi".AsOsAgnostic(), + Episodes = episodes, + Series = _series + }; + + Mocker.GetMock() + .Setup(s => s.RequiresEpisodeTitle(_series, episodes)) + .Returns(true); + } + + [Test] + public void should_reject_when_title_is_null() + { + _localEpisode.Episodes.First().Title = null; + + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_reject_when_title_is_TBA() + { + _localEpisode.Episodes.First().Title = "TBA"; + + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_accept_when_did_not_air_recently_but_title_is_TBA() + { + _localEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow.AddDays(-7); + _localEpisode.Episodes.First().Title = "TBA"; + + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_accept_when_episode_title_is_not_required() + { + _localEpisode.Episodes.First().Title = "TBA"; + + Mocker.GetMock() + .Setup(s => s.RequiresEpisodeTitle(_series, _localEpisode.Episodes)) + .Returns(false); + + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); + } + } +} 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..e2704af6b 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecificationFixture.cs @@ -1,4 +1,4 @@ -using FizzWare.NBuilder; +using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; @@ -22,7 +22,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications Path = @"C:\Test\30 Rock\30.rock.s01e01.avi".AsOsAgnostic(), Size = 100, Series = Builder.CreateNew().Build(), - ParsedEpisodeInfo = new ParsedEpisodeInfo + FileEpisodeInfo = new ParsedEpisodeInfo { FullSeason = false } @@ -32,15 +32,15 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications [Test] public void should_return_false_when_file_contains_the_full_season() { - _localEpisode.ParsedEpisodeInfo.FullSeason = true; + _localEpisode.FileEpisodeInfo.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..dc6880cd6 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecificationFixture.cs @@ -1,4 +1,4 @@ -using FizzWare.NBuilder; +using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; @@ -18,9 +18,16 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications { _localEpisode = Builder.CreateNew() .With(l => l.Path = @"C:\Test\Unsorted\Series.Title.S01E01.720p.HDTV-Sonarr\S01E05.mkv".AsOsAgnostic()) - .With(l => l.ParsedEpisodeInfo = + .With(l => l.FileEpisodeInfo = Builder.CreateNew() - .With(p => p.EpisodeNumbers = new[] {5}) + .With(p => p.EpisodeNumbers = new[] { 5 }) + .With(p => p.SeasonNumber == 1) + .With(p => p.FullSeason = false) + .Build()) + .With(l => l.FolderEpisodeInfo = + Builder.CreateNew() + .With(p => p.EpisodeNumbers = new[] { 1 }) + .With(p => p.SeasonNumber == 1) .With(p => p.FullSeason = false) .Build()) .Build(); @@ -31,54 +38,89 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications { _localEpisode.ExistingFile = true; - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] public void should_be_accepted_if_folder_name_is_not_parseable() { _localEpisode.Path = @"C:\Test\Unsorted\Series.Title\S01E01.mkv".AsOsAgnostic(); + _localEpisode.FolderEpisodeInfo = null; - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] public void should_should_be_accepted_for_full_season() { _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01\S01E01.mkv".AsOsAgnostic(); + _localEpisode.FolderEpisodeInfo.EpisodeNumbers = new int[0]; + _localEpisode.FolderEpisodeInfo.FullSeason = true; - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); } [Test] public void should_be_accepted_if_file_and_folder_have_the_same_episode() { - _localEpisode.ParsedEpisodeInfo.EpisodeNumbers = new[] { 1 }; + _localEpisode.FileEpisodeInfo.EpisodeNumbers = new[] { 1 }; + _localEpisode.FolderEpisodeInfo.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] public void should_be_accepted_if_file_is_one_episode_in_folder() { - _localEpisode.ParsedEpisodeInfo.EpisodeNumbers = new[] { 1 }; + _localEpisode.FileEpisodeInfo.EpisodeNumbers = new[] { 1 }; + _localEpisode.FolderEpisodeInfo.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_disregard_subfolder() + { + _localEpisode.FileEpisodeInfo.EpisodeNumbers = new[] { 5, 6 }; + _localEpisode.FolderEpisodeInfo.EpisodeNumbers = new[] { 1, 2 }; + _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01E01E02.720p.HDTV-Sonarr\S01E05E06.mkv".AsOsAgnostic(); + + Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); } [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] - public void should_be_rejected_if_file_and_folder_do_not_have_same_episodes() + public void should_be_rejected_if_file_and_folder_do_not_have_the_same_episodes() { - _localEpisode.ParsedEpisodeInfo.EpisodeNumbers = new[] { 5, 6 }; + _localEpisode.FileEpisodeInfo.EpisodeNumbers = new[] { 5, 6 }; + _localEpisode.FolderEpisodeInfo.EpisodeNumbers = new[] { 1, 2 }; _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(); + } + + [Test] + public void should_be_rejected_if_file_and_folder_do_not_have_episodes_from_the_same_season() + { + _localEpisode.FileEpisodeInfo.SeasonNumber = 2; + _localEpisode.FileEpisodeInfo.EpisodeNumbers = new[] { 1 }; + + _localEpisode.FolderEpisodeInfo.FullSeason = true; + _localEpisode.FolderEpisodeInfo.SeasonNumber = 1; + _localEpisode.FolderEpisodeInfo.EpisodeNumbers = new[] { 1, 2 }; + + _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01.720p.HDTV-Sonarr\S02E01.mkv".AsOsAgnostic(); + + 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..4b2bd7745 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs @@ -58,10 +58,7 @@ namespace NzbDrone.Core.Test.MediaFiles Episodes = new List { episode }, Path = Path.Combine(series.Path, "30 Rock - S01E01 - Pilot.avi"), Quality = new QualityModel(Quality.Bluray720p), - ParsedEpisodeInfo = new ParsedEpisodeInfo - { - ReleaseGroup = "DRONE" - } + ReleaseGroup = "DRONE" })); } @@ -224,9 +221,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 +232,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..59f86e4e6 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteEpisodeFileFixture.cs @@ -0,0 +1,143 @@ +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)); + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_should_throw_if_root_folder_is_empty() + { + GivenRootFolderExists(); + + Assert.Throws(() => Subject.DeleteEpisodeFile(_series, _episodeFile)); + ExceptionVerification.ExpectedWarns(1); + } + + [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 52% rename from src/NzbDrone.Core.Test/MediaFiles/MediaInfo/FormattedAudioChannelsFixture.cs rename to src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioChannelsFixture.cs index c344c0906..7a80c9af4 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 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,21 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo SchemaRevision = 3 }; - mediaInfoModel.FormattedAudioChannels.Should().Be(7.1m); + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(7.1m); + } + + [Test] + public void should_skip_empty_groups_in_AudioChannelPositions() + { + var mediaInfoModel = new MediaInfoModel + { + AudioChannels = 2, + AudioChannelPositions = " / 2/0/0.0", + AudioChannelPositionsText = null, + SchemaRevision = 3 + }; + + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(2); } [Test] @@ -114,7 +129,49 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo SchemaRevision = 3 }; - mediaInfoModel.FormattedAudioChannels.Should().Be(7.1m); + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(7.1m); + } + + [Test] + public void should_sum_dual_mono_representation_AudioChannelPositions() + { + var mediaInfoModel = new MediaInfoModel + { + AudioChannels = 2, + AudioChannelPositions = "1+1", + AudioChannelPositionsText = null, + SchemaRevision = 3 + }; + + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(2.0m); + } + + [Test] + public void should_use_AudioChannelPositionText_when_AudioChannelChannelPosition_is_invalid() + { + var mediaInfoModel = new MediaInfoModel + { + AudioChannels = 6, + AudioChannelPositions = "15 objects", + AudioChannelPositionsText = "15 objects / Front: L C R, Side: L R, LFE", + SchemaRevision = 3 + }; + + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(5.1m); + } + + [Test] + public void should_remove_atmos_objects_from_AudioChannelPostions() + { + var mediaInfoModel = new MediaInfoModel + { + AudioChannels = 2, + AudioChannelPositions = "15 objects / 3/2.1", + AudioChannelPositionsText = null, + SchemaRevision = 3 + }; + + MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(5.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..e0bd875f5 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioCodecFixture.cs @@ -0,0 +1,74 @@ +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")] + [TestCase("Opus, A_OPUS, , ", "Roadkill Ep3x11 - YouTube.webm", "Opus")] + [TestCase("mp3 , 0, , ", "climbing.mp4", "MP3")] + 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..179187292 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatVideoCodecFixture.cs @@ -0,0 +1,109 @@ +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_legacy(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")] + [TestCase("div3, div3, , ", "spsm.dvdrip.divx.avi'.", "DivX")] + [TestCase("VP6, 4, , ", "Top Gear - S12E01 - Lorries - SD TV.flv", "VP6")] + [TestCase("VP7, VP70, General, ", "Sweet Seymour.avi", "VP7")] + [TestCase("VP8, V_VP8, , ", "Dick.mkv", "VP8")] + [TestCase("VP9, V_VP9, , ", "Roadkill Ep3x11 - YouTube.webm", "VP9")] + [TestCase("x264, x264, , ", "Ghost Advent - S04E05 - Stanley Hotel SDTV.avi", "x264")] + [TestCase("V_MPEGH/ISO/HEVC, V_MPEGH/ISO/HEVC, , ", "The BBT S11E12 The Matrimonial Metric 1080p 10bit AMZN WEB-DL", "h265")] + [TestCase("MPEG-4 Visual, 20, Simple@L1, Lavc52.29.0", "Will.And.Grace.S08E14.WS.DVDrip.XviD.I.Love.L.Gay-Obfuscated", "XviD")] + [TestCase("MPEG-4 Visual, 20, Advanced Simple@L5, XviD0046", "", "XviD")] + [TestCase("mp4v, mp4v, , ", "American.Chopper.S06E07.Mountain.Creek.Bike.DSR.XviD-KRS", "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); + } + + [TestCase("AVC, AVC, , x264", "Some.Video.S01E01.h264", "x264")] // Force mediainfo tag + [TestCase("HEVC, HEVC, , x265", "Some.Video.S01E01.h265", "x265")] // Force mediainfo tag + [TestCase("AVC, AVC, , ", "Some.Video.S01E01.x264", "x264")] // Not seen in practice, but honor tag if otherwise unknown + [TestCase("HEVC, HEVC, , ", "Some.Video.S01E01.x265", "x265")] // Not seen in practice, but honor tag if otherwise unknown + [TestCase("AVC, AVC, , ", "Some.Video.S01E01", "h264")] // Default value + [TestCase("HEVC, HEVC, , ", "Some.Video.S01E01", "h265")] // Default value + public void should_format_video_format_fallbacks(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); + } + + [TestCase("MPEG-4 Visual, 20, , Intel(R) MPEG-4 encoder based on Intel(R) IPP 6.1 build 137.20[6.1.137.763]", "", "")] + public void should_warn_on_unknown_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); + ExceptionVerification.ExpectedWarns(1); + } + + [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..a4e33ac35 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 = VideoFileInfoReader.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 = VideoFileInfoReader.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..febeaf416 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs @@ -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().BeOneOf("40", "mp4a-40-2"); + 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().BeOneOf("40", "mp4a-40-2"); + 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..755800253 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; @@ -6,6 +7,7 @@ using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; @@ -31,10 +33,17 @@ namespace NzbDrone.Core.Test.MediaFiles .CreateNew() .Build(); + Mocker.GetMock() + .Setup(c => c.FolderExists(Directory.GetParent(_localEpisode.Series.Path).FullName)) + .Returns(true); Mocker.GetMock() - .Setup(c => c.FileExists(It.IsAny())) - .Returns(true); + .Setup(c => c.FileExists(It.IsAny())) + .Returns(true); + + Mocker.GetMock() + .Setup(c => c.GetParentFolder(It.IsAny())) + .Returns(c => Path.GetDirectoryName(c)); } private void GivenSingleEpisodeWithSingleEpisodeFile() @@ -171,5 +180,19 @@ namespace NzbDrone.Core.Test.MediaFiles Subject.UpgradeEpisodeFile(_episodeFile, _localEpisode).OldFiles.Count.Should().Be(2); } + + [Test] + public void should_throw_if_there_are_existing_episode_files_and_the_root_folder_is_missing() + { + GivenSingleEpisodeWithSingleEpisodeFile(); + + Mocker.GetMock() + .Setup(c => c.FolderExists(Directory.GetParent(_localEpisode.Series.Path).FullName)) + .Returns(false); + + Assert.Throws(() => Subject.UpgradeEpisodeFile(_episodeFile, _localEpisode)); + + Mocker.GetMock().Verify(v => v.Delete(_localEpisode.Episodes.Single().EpisodeFile.Value, DeleteMediaFileReason.Upgrade), Times.Never()); + } } } diff --git a/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs b/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs index 4a039e699..0f9435ae6 100644 --- a/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs +++ b/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs @@ -1,121 +1,221 @@ -//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); + } + + [TearDown] + public void TearDown() + { + Subject.Handle(new ApplicationShutdownRequested()); + + // Give the threads a bit of time to shut down. + Thread.Sleep(10); + } + + private void GivenCommandQueue() + { + _commandQueue = new BlockingCollection(new CommandQueue()); + + Mocker.GetMock() + .Setup(s => s.Queue(It.IsAny())) + .Returns(_commandQueue.GetConsumingEnumerable); + } + + private void QueueAndWaitForExecution(CommandModel commandModel) + { + Thread.Sleep(10); + + 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); + + _commandQueue.Add(commandModel); + + while (!_commandExecuted) + { + Thread.Sleep(100); + } + + Thread.Sleep(10); + } + + [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()); + + QueueAndWaitForExecution(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()); + + QueueAndWaitForExecution(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()); + + QueueAndWaitForExecution(commandModel); + + VerifyEventPublished(); + + Thread.Sleep(10); + + 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()); + + QueueAndWaitForExecution(commandModel); + + VerifyEventPublished(); + } + + [Test] + public void should_use_completion_message() + { + GivenCommandQueue(); + var commandA = new CommandA(); + var commandModel = new CommandModel + { + Body = commandA + }; + + Subject.Handle(new ApplicationStartedEvent()); + + QueueAndWaitForExecution(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()); + + QueueAndWaitForExecution(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; + } + +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index f8e7b07c9..ab85a93a4 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -77,6 +77,10 @@ ..\packages\Unity.2.1.505.2\lib\NET35\Microsoft.Practices.Unity.Configuration.dll True + + ..\packages\Moq.4.0.10827\lib\NET40\Moq.dll + True + ..\packages\NCrunch.Framework.3.2.0.3\lib\NCrunch.Framework.dll True @@ -86,20 +90,22 @@ True - ..\packages\NLog.4.4.3\lib\net40\NLog.dll + ..\packages\NLog.4.5.3\lib\net40-client\NLog.dll ..\packages\NUnit.3.6.0\lib\net40\nunit.framework.dll + + + + + - - ..\packages\Moq.4.0.10827\lib\NET40\Moq.dll - ..\packages\Prowlin.0.9.4456.26422\lib\net40\Prowlin.dll @@ -114,6 +120,16 @@ + + + + + + + + + + @@ -137,6 +153,7 @@ + @@ -145,6 +162,7 @@ + @@ -159,10 +177,13 @@ + + + @@ -172,11 +193,13 @@ + + @@ -190,6 +213,7 @@ + @@ -202,6 +226,7 @@ Always + @@ -217,6 +242,7 @@ + @@ -234,9 +260,11 @@ + - + + @@ -251,6 +279,7 @@ + @@ -278,31 +307,43 @@ + + + - + + + + + - + + + + + + - + @@ -312,8 +353,8 @@ - - + + @@ -363,7 +404,8 @@ - + + @@ -383,9 +425,11 @@ + +
@@ -419,6 +463,10 @@ sqlite3.dll Always + + Designer + Always + Always Designer @@ -426,6 +474,9 @@ Always + + Always + Always @@ -549,6 +600,12 @@ + + Always + + + Always + Always @@ -567,6 +624,9 @@ Always + + Always + diff --git a/src/NzbDrone.Core.Test/OrganizerTests/CleanFilenameFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/CleanFilenameFixture.cs new file mode 100644 index 000000000..144a38934 --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/CleanFilenameFixture.cs @@ -0,0 +1,30 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.OrganizerTests +{ + [TestFixture] + public class CleanFilenameFixture : CoreTest + { + [TestCase("Law & Order: Criminal Intent - S10E07 - Icarus [HDTV-720p]", "Law & Order- Criminal Intent - S10E07 - Icarus [HDTV-720p]")] + public void should_replaace_invalid_characters(string name, string expectedName) + { + FileNameBuilder.CleanFileName(name).Should().Be(expectedName); + } + + [TestCase(".hack s01e01", "hack s01e01")] + public void should_remove_periods_from_start(string name, string expectedName) + { + FileNameBuilder.CleanFileName(name).Should().Be(expectedName); + } + + [TestCase(" Series Title - S01E01 - Episode Title", "Series Title - S01E01 - Episode Title")] + [TestCase("Series Title - S01E01 - Episode Title ", "Series Title - S01E01 - Episode Title")] + public void should_remove_spaces_from_start_and_end(string name, string expectedName) + { + FileNameBuilder.CleanFileName(name).Should().Be(expectedName); + } + } +} diff --git a/src/NzbDrone.Core.Test/OrganizerTests/CleanFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/CleanFixture.cs deleted file mode 100644 index 9e8600104..000000000 --- a/src/NzbDrone.Core.Test/OrganizerTests/CleanFixture.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Organizer; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.OrganizerTests -{ - [TestFixture] - public class CleanFixture : CoreTest - { - [TestCase("Law & Order: Criminal Intent - S10E07 - Icarus [HDTV-720p]", - "Law & Order- Criminal Intent - S10E07 - Icarus [HDTV-720p]")] - public void CleanFileName(string name, string expectedName) - { - FileNameBuilder.CleanFileName(name).Should().Be(expectedName); - } - - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/RequiresEpisodeTitleFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/RequiresEpisodeTitleFixture.cs new file mode 100644 index 000000000..3837c29b5 --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/RequiresEpisodeTitleFixture.cs @@ -0,0 +1,57 @@ +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 RequiresEpisodeTitleFixture : 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(); + + _namingConfig = NamingConfig.Default; + _namingConfig.RenameEpisodes = true; + + Mocker.GetMock() + .Setup(c => c.GetConfig()).Returns(_namingConfig); + } + + [Test] + public void should_return_false_when_episode_title_is_not_part_of_the_pattern() + { + _namingConfig.StandardEpisodeFormat = "{Series Title} S{season:00}E{episode:00}"; + Subject.RequiresEpisodeTitle(_series, new List { _episode }).Should().BeFalse(); + } + + [Test] + public void should_return_true_when_episode_title_is_part_of_the_pattern() + { + Subject.RequiresEpisodeTitle(_series, new List { _episode }).Should().BeTrue(); + } + } +} 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/AbsoluteEpisodeNumberParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs index 9cdbf08e4..18f14728f 100644 --- a/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs @@ -85,6 +85,10 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("[Cthuyuu] Taimadou Gakuen 35 Shiken Shoutai - 03 [720p H264 AAC][8AD82C3A]", "Taimadou Gakuen 35 Shiken Shoutai", 3, 0, 0)] [TestCase("Dragon Ball Super Episode 56 [VOSTFR V2][720p][AAC]-Mystic Z-Team", "Dragon Ball Super", 56, 0, 0)] [TestCase("[Mystic Z-Team] Dragon Ball Super Episode 69 [VOSTFR_Finale][1080p][AAC].mp4", "Dragon Ball Super", 69, 0, 0)] + [TestCase("[Shark-Raws] Crayon Shin-chan #957 (NBN 1280x720 x264 AAC).mp4", "Crayon Shin-chan", 957, 0, 0)] + [TestCase("Love Rerun EP06 720p x265 AOZ.mp4", "Love Rerun", 6, 0, 0)] + [TestCase("Love Rerun 2018 EP06 720p x265 AOZ.mp4", "Love Rerun 2018", 6, 0, 0)] + [TestCase("Love Rerun 2018 06 720p x265 AOZ.mp4", "Love Rerun 2018", 6, 0, 0)] //[TestCase("", "", 0, 0, 0)] public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber) { diff --git a/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs b/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs index 11f68da85..0db07c03f 100644 --- a/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs @@ -39,5 +39,12 @@ namespace NzbDrone.Core.Test.ParserTests { Parser.Parser.ParseTitle(title).IsPossibleSpecialEpisode.Should().BeTrue(); } + + + [TestCase("Dr.S11E00.A.Christmas.Carol.Special.720p.HDTV-FieldOfView")] + public void IsPossibleSpecialEpisode_should_be_true_if_e00_special(string title) + { + Parser.Parser.ParseTitle(title).IsPossibleSpecialEpisode.Should().BeTrue(); + } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs index c86948b17..306e5290b 100644 --- a/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Parser; using NzbDrone.Core.Test.Framework; @@ -46,6 +46,9 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Castle.2009.S01E14.HDTV.XviD.HUNDUB-LOL", Language.Hungarian)] [TestCase("Castle.2009.S01E14.HDTV.XviD.ENG.HUN-LOL", Language.Hungarian)] [TestCase("Castle.2009.S01E14.HDTV.XviD.HUN-LOL", Language.Hungarian)] + [TestCase("Avatar.The.Last.Airbender.S01-03.DVDRip.HebDub",Language.Hebrew)] + [TestCase("Prison.Break.S05E01.WEBRip.x264.AC3.LT.EN-CNN", Language.Lithuanian)] + [TestCase("The.​Walking.​Dead.​S07E11.​WEB Rip.​XviD.​Louige-​CZ.​EN.​5.​1", Language.Czech)] public void should_parse_language(string postTitle, Language language) { var result = LanguageParser.ParseLanguage(postTitle); diff --git a/src/NzbDrone.Core.Test/ParserTests/MultiEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/MultiEpisodeParserFixture.cs index 9d694c665..3aea7ccaf 100644 --- a/src/NzbDrone.Core.Test/ParserTests/MultiEpisodeParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/MultiEpisodeParserFixture.cs @@ -57,6 +57,9 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Series Title.S6.E1-E2-E3.Episode Name.1080p.WEB-DL", "Series Title", 6, new[] { 1, 2, 3 })] [TestCase("Mad.Men.S05E01-E02.720p.5.1Ch.BluRay", "Mad Men", 5, new[] { 1, 2 })] [TestCase("Mad.Men.S05E01-02.720p.5.1Ch.BluRay", "Mad Men", 5, new[] { 1, 2 })] + [TestCase("S01E01-E03 - Episode Title.HDTV-720p", "", 1, new [] { 1, 2, 3 })] + [TestCase("1x01-x03 - Episode Title.HDTV-720p", "", 1, new [] { 1, 2, 3 })] + [TestCase("Are.You.Human.Too.E07-E08.180612.1080p-NEXT", "Are You Human Too", 1, new[] { 7, 8 })] //[TestCase("", "", , new [] { })] public void should_parse_multiple_episodes(string postTitle, string title, int season, int[] episodes) { diff --git a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs index 2712c8dbf..bdc6d09ac 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 @@ -35,6 +37,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Match of the Day", "matchday")] [TestCase("Match of the Day 2", "matchday2")] [TestCase("[ www.Torrenting.com ] - Revenge.S03E14.720p.HDTV.X264-DIMENSION", "Revenge")] + [TestCase("www.Torrenting.com - Revenge.S03E14.720p.HDTV.X264-DIMENSION", "Revenge")] [TestCase("Seed S02E09 HDTV x264-2HD [eztv]-[rarbg.com]", "Seed")] [TestCase("Reno.911.S01.DVDRip.DD2.0.x264-DEEP", "Reno 911")] public void should_parse_series_name(string postTitle, string title) @@ -47,7 +50,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 +65,21 @@ 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.QualityDetectionSource.Should().Be(QualityDetectionSource.Extension); + } + + + [TestCase("Revolution.S01E02.Chained.Heat.mkv", "Revolution.S01E02.Chained.Heat")] + public void should_parse_releasetitle(string path, string releaseTitle) + { + var result = Parser.Parser.ParseTitle(path); + result.ReleaseTitle.Should().Be(releaseTitle); + } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs index 7221038e7..8103cb483 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using FizzWare.NBuilder; +using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Core.DataAugmentation.Scene; @@ -117,6 +118,10 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests { GivenAbsoluteNumberingSeries(); + Mocker.GetMock() + .Setup(s => s.FindEpisodesBySceneNumbering(It.IsAny(), It.IsAny())) + .Returns(new List()); + Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); Mocker.GetMock() @@ -192,7 +197,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests GivenAbsoluteNumberingSeries(); _parsedEpisodeInfo.Special = true; - + Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); Mocker.GetMock() @@ -210,7 +215,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 +239,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() @@ -253,18 +258,44 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests [TestCase(0)] [TestCase(1)] [TestCase(2)] - public void should_find_episode_by_season_and_absolute_episode_number_when_scene_absolute_episode_number_returns_multiple_results(int seasonNumber) + public void should_return_episodes_when_scene_absolute_episode_number_returns_multiple_results(int seasonNumber) { GivenAbsoluteNumberingSeries(); Mocker.GetMock() - .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle)) + .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle, It.IsAny())) .Returns(seasonNumber); Mocker.GetMock() .Setup(s => s.FindEpisodesBySceneNumbering(It.IsAny(), seasonNumber, It.IsAny())) .Returns(Builder.CreateListOfSize(5).Build().ToList()); + var result = Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); + + result.Should().HaveCount(5); + + Mocker.GetMock() + .Verify(v => v.FindEpisodesBySceneNumbering(It.IsAny(), seasonNumber, It.IsAny()), Times.Once()); + + Mocker.GetMock() + .Verify(v => v.FindEpisode(It.IsAny(), seasonNumber, It.IsAny()), Times.Never()); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + public void should_find_episode_by_season_and_absolute_episode_number_when_scene_absolute_episode_number_returns_no_results(int seasonNumber) + { + GivenAbsoluteNumberingSeries(); + + Mocker.GetMock() + .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle, It.IsAny())) + .Returns(seasonNumber); + + Mocker.GetMock() + .Setup(s => s.FindEpisodesBySceneNumbering(It.IsAny(), seasonNumber, It.IsAny())) + .Returns(new List()); + Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); Mocker.GetMock() @@ -280,7 +311,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 +329,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 +361,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/PathParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/PathParserFixture.cs index 9dfdeb851..36443e53e 100644 --- a/src/NzbDrone.Core.Test/ParserTests/PathParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/PathParserFixture.cs @@ -34,6 +34,7 @@ namespace NzbDrone.Core.Test.ParserTests public void should_parse_from_path(string path, int season, int episode) { var result = Parser.Parser.ParsePath(path.AsOsAgnostic()); + result.EpisodeNumbers.Should().HaveCount(1); result.SeasonNumber.Should().Be(season); result.EpisodeNumbers[0].Should().Be(episode); @@ -42,5 +43,20 @@ namespace NzbDrone.Core.Test.ParserTests ExceptionVerification.IgnoreWarns(); } + + [TestCase("01-03\\The Series Title (2010) - 1x01-02-03 - Episode Title HDTV-720p Proper", "The Series Title (2010)", 1, new [] { 1, 2, 3 })] + public void should_parse_multi_episode_from_path(string path, string title, int season, int[] episodes) + { + var result = Parser.Parser.ParsePath(path.AsOsAgnostic()); + + result.SeriesTitle.Should().Be(title); + result.EpisodeNumbers.Should().HaveCount(episodes.Length); + result.SeasonNumber.Should().Be(season); + result.EpisodeNumbers.Should().BeEquivalentTo(episodes); + result.AbsoluteEpisodeNumbers.Should().BeEmpty(); + result.FullSeason.Should().BeFalse(); + + ExceptionVerification.IgnoreWarns(); + } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs index e5a187c5c..17397888f 100644 --- a/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Parser; using NzbDrone.Core.Qualities; @@ -92,6 +92,8 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Glee.S04E10.Glee.Actually.480p.WEB-DL.x264-mSD", false)] [TestCase("The.Big.Bang.Theory.S06E11.The.Santa.Simulation.480p.WEB-DL.x264-mSD", false)] [TestCase("Da.Vincis.Demons.S02E04.480p.WEB.DL.nSD.x264-NhaNc3", false)] + [TestCase("Incorporated.S01E08.Das.geloeschte.Ich.German.Dubbed.DL.AmazonHD.x264-TVS", false)] + [TestCase("Haters.Back.Off.S01E04.Rod.Trip.mit.meinem.Onkel.German.DL.NetflixUHD.x264", false)] public void should_parse_webdl480p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.WEBDL480p, proper); @@ -115,6 +117,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("[Eveyuu] No Game No Life - 10 [Hi10P 1280x720 H264][10B23BD8]", false)] [TestCase("Hells.Kitchen.US.S12E17.HR.WS.PDTV.X264-DIMENSION", false)] [TestCase("Survivorman.The.Lost.Pilots.Summer.HR.WS.PDTV.x264-DHD", false)] + [TestCase("Victoria S01E07 - Motor zmen (CZ)[TvRip][HEVC][720p]", false)] public void should_parse_hdtv720p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.HDTV720p, proper); @@ -126,6 +129,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("DEXTER.S07E01.ARE.YOU.1080P.HDTV.proper.X264-QCF", true)] [TestCase("Dexter - S01E01 - Title [HDTV-1080p]", false)] [TestCase("[HorribleSubs] Yowamushi Pedal - 32 [1080p]", false)] + [TestCase("Victoria S01E07 - Motor zmen (CZ)[TvRip][HEVC][1080p]", false)] public void should_parse_hdtv1080p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.HDTV1080p, proper); @@ -145,6 +149,11 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Castle.S06E23.720p.WebHD.h264-euHD", false)] [TestCase("The.Nightly.Show.2016.03.14.720p.WEB.x264-spamTV", false)] [TestCase("The.Nightly.Show.2016.03.14.720p.WEB.h264-spamTV", false)] + [TestCase("Incorporated.S01E08.Das.geloeschte.Ich.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS", false)] + [TestCase("Marco.Polo.S01E11.One.Hundred.Eyes.2015.German.DD51.DL.720p.NetflixUHD.x264.NewUp.by.Wunschtante", false)] + [TestCase("Hush 2016 German DD51 DL 720p NetflixHD x264-TVS", false)] + [TestCase("Community.6x10.Basic.RV.Repair.and.Palmistry.ITA.ENG.720p.WEB-DLMux.H.264-GiuseppeTnT", false)] + [TestCase("Community.6x11.Modern.Espionage.ITA.ENG.720p.WEB.DLMux.H.264-GiuseppeTnT", false)] public void should_parse_webdl720p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.WEBDL720p, proper); @@ -166,6 +175,9 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Series Title S06E08 No One PROPER 1080p WEB DD5 1 H 264-EXCLUSIVE", true)] [TestCase("Series Title S06E08 No One PROPER 1080p WEB H 264-EXCLUSIVE", true)] [TestCase("The.Simpsons.S25E21.Pay.Pal.1080p.WEB-DL.DD5.1.H.264-NTb", false)] + [TestCase("Incorporated.S01E08.Das.geloeschte.Ich.German.DD51.Dubbed.DL.1080p.AmazonHD.x264-TVS", false)] + [TestCase("Death.Note.2017.German.DD51.DL.1080p.NetflixHD.x264-TVS", false)] + [TestCase("Played.S01E08.Pro.Gamer.1440p.BKPL.WEB-DL.H.264-LiGHT", false)] public void should_parse_webdl1080p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.WEBDL1080p, proper); @@ -178,6 +190,8 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("The.Nightly.Show.2016.03.14.2160p.WEB.x264-spamTV", false)] [TestCase("The.Nightly.Show.2016.03.14.2160p.WEB.h264-spamTV", false)] [TestCase("The.Nightly.Show.2016.03.14.2160p.WEB.PROPER.h264-spamTV", true)] + [TestCase("House.of.Cards.US.s05e13.4K.UHD.WEB.DL", false)] + [TestCase("House.of.Cards.US.s05e13.UHD.4K.WEB.DL", false)] public void should_parse_webdl2160p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.WEBDL2160p, proper); @@ -215,6 +229,13 @@ namespace NzbDrone.Core.Test.ParserTests ParseAndVerifyQuality(title, Quality.Bluray1080p, proper); } + [TestCase("House.of.Cards.US.s05e13.4K.UHD.Bluray", false)] + [TestCase("House.of.Cards.US.s05e13.UHD.4K.Bluray", false)] + public void should_parse_bluray2160p_quality(string title, bool proper) + { + ParseAndVerifyQuality(title, Quality.Bluray2160p, proper); + } + [TestCase("POI S02E11 1080i HDTV DD5.1 MPEG2-TrollHD", false)] [TestCase("How I Met Your Mother S01E18 Nothing Good Happens After 2 A.M. 720p HDTV DD5.1 MPEG2-TrollHD", false)] [TestCase("The Voice S01E11 The Finals 1080i HDTV DD5.1 MPEG2-TrollHD", false)] @@ -263,7 +284,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("White.Van.Man.2011.S02E01.WS.PDTV.x264-REPACK-TLA")] public void should_parse_quality_from_name(string title) { - QualityParser.ParseQuality(title).QualitySource.Should().Be(QualitySource.Name); + QualityParser.ParseQuality(title).QualityDetectionSource.Should().Be(QualityDetectionSource.Name); } [TestCase("Revolution.S01E02.Chained.Heat.mkv")] @@ -272,7 +293,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("[CR] Sailor Moon - 004 [48CE2D0F].avi")] public void should_parse_quality_from_extension(string title) { - QualityParser.ParseQuality(title).QualitySource.Should().Be(QualitySource.Extension); + QualityParser.ParseQuality(title).QualityDetectionSource.Should().Be(QualityDetectionSource.Extension); } private void ParseAndVerifyQuality(string title, Quality quality, bool proper) diff --git a/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs index 18fd75856..91cf503a3 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs @@ -26,6 +26,9 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("[ www.Torrenting.com ] - Revenge.S03E14.720p.HDTV.X264-DIMENSION", "DIMENSION")] [TestCase("Seed S02E09 HDTV x264-2HD [eztv]-[rarbg.com]", "2HD")] [TestCase("7s-atlantis-s02e01-720p.mkv", null)] + [TestCase("The.Middle.S09E13.720p.HEVC.x265-MeGusta-Pre", "MeGusta")] + [TestCase("Ghosted.S01E08.Haunted.Hayride.720p.AMZN.WEBRip.DDP5.1.x264-NTb-postbot", "NTb")] + [TestCase("Ghosted.S01E08.Haunted.Hayride.720p.AMZN.WEBRip.DDP5.1.x264-NTb-xpost", "NTb")] //[TestCase("", "")] public void should_parse_release_group(string title, string expected) { @@ -57,6 +60,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/SeasonParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs index 7a4ed0b9f..7272d474b 100644 --- a/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs @@ -34,24 +34,47 @@ namespace NzbDrone.Core.Test.ParserTests result.FullSeason.Should().BeTrue(); } - [TestCase("Acropolis Now S05 EXTRAS DVDRip XviD RUNNER")] - [TestCase("Punky Brewster S01 EXTRAS DVDRip XviD RUNNER")] - [TestCase("Instant Star S03 EXTRAS DVDRip XviD OSiTV")] - public void should_parse_season_extras(string postTitle) + [TestCase("Acropolis Now S05 EXTRAS DVDRip XviD RUNNER", "Acropolis Now", 5)] + [TestCase("Punky Brewster S01 EXTRAS DVDRip XviD RUNNER", "Punky Brewster", 1)] + [TestCase("Instant Star S03 EXTRAS DVDRip XviD OSiTV", "Instant Star", 3)] + [TestCase("The.Flash.S03.Extras.01.Deleted.Scenes.720p", "The Flash", 3)] + [TestCase("The.Flash.S03.Extras.02.720p", "The Flash", 3)] + public void should_parse_season_extras(string postTitle, string title, int season) { var result = Parser.Parser.ParseTitle(postTitle); - - result.Should().BeNull(); + result.SeasonNumber.Should().Be(season); + result.SeriesTitle.Should().Be(title); + result.EpisodeNumbers.Should().BeEmpty(); + result.AbsoluteEpisodeNumbers.Should().BeEmpty(); + result.FullSeason.Should().BeTrue(); + result.IsSeasonExtra.Should().BeTrue(); } - [TestCase("Lie.to.Me.S03.SUBPACK.DVDRip.XviD-REWARD")] - [TestCase("The.Middle.S02.SUBPACK.DVDRip.XviD-REWARD")] - [TestCase("CSI.S11.SUBPACK.DVDRip.XviD-REWARD")] - public void should_parse_season_subpack(string postTitle) + [TestCase("Lie.to.Me.S03.SUBPACK.DVDRip.XviD-REWARD", "Lie to Me", 3)] + [TestCase("The.Middle.S02.SUBPACK.DVDRip.XviD-REWARD", "The Middle", 2)] + [TestCase("CSI.S11.SUBPACK.DVDRip.XviD-REWARD", "CSI", 11)] + public void should_parse_season_subpack(string postTitle, string title, int season) { var result = Parser.Parser.ParseTitle(postTitle); + result.SeasonNumber.Should().Be(season); + result.SeriesTitle.Should().Be(title); + result.EpisodeNumbers.Should().BeEmpty(); + result.AbsoluteEpisodeNumbers.Should().BeEmpty(); + result.FullSeason.Should().BeTrue(); + result.IsSeasonExtra.Should().BeTrue(); + } - result.Should().BeNull(); + [TestCase("The.Ranch.2016.S02.Part.1.1080p.NF.WEBRip.DD5.1.x264-NTb", "The Ranch 2016", 2, 1)] + public void should_parse_partial_season_release(string postTitle, string title, int season, int seasonPart) + { + var result = Parser.Parser.ParseTitle(postTitle); + result.SeasonNumber.Should().Be(season); + result.SeriesTitle.Should().Be(title); + result.EpisodeNumbers.Should().BeEmpty(); + result.AbsoluteEpisodeNumbers.Should().BeEmpty(); + result.FullSeason.Should().BeFalse(); + result.IsPartialSeason.Should().BeTrue(); + result.SeasonPart.Should().Be(seasonPart); } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs index 05cdaa6eb..cad0042e3 100644 --- a/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs @@ -127,6 +127,13 @@ 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("Room 104 - S01E07 The Missionaries [SDTV]", "Room 104", 1, 7)] + [TestCase("11-02 The Retraction Reaction (HD).m4v", "", 11, 2)] + [TestCase("Plus belle la vie - S14E3533 FRENCH WEBRIP H.264 AAC (09.05.2018)", "Plus belle la vie", 14, 3533)] + [TestCase("The 100 - S01E02 - Earth Skills HDTV-1080p AVC DTS [EN+FR+ES+PT+DA+FI+NB+SV]", "The 100", 1, 2)] + [TestCase("Series Title - S01E01 - Day 100 [SDTV]", "Series Title", 1, 1)] //[TestCase("", "", 0, 0)] public void should_parse_single_episode(string postTitle, string title, int seasonNumber, int episodeNumber) { diff --git a/src/NzbDrone.Core.Test/ParserTests/ValidateParsedEpisodeInfoFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ValidateParsedEpisodeInfoFixture.cs new file mode 100644 index 000000000..656a06b8f --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/ValidateParsedEpisodeInfoFixture.cs @@ -0,0 +1,73 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.ParserTests +{ + [TestFixture] + public class ValidateParsedEpisodeInfoFixture : CoreTest + { + private ParsedEpisodeInfo _parsedEpisodeInfo; + private Series _series; + + [SetUp] + public void Setup() + { + _parsedEpisodeInfo = Builder.CreateNew() + .With(p => p.AirDate = null) + .Build(); + + _series = Builder.CreateNew() + .With(s => s.SeriesType = SeriesTypes.Standard) + .Build(); + } + + private void GivenDailyParsedEpisodeInfo() + { + _parsedEpisodeInfo.AirDate = "2018-05-21"; + } + + private void GivenDailySeries() + { + _series.SeriesType = SeriesTypes.Daily; + } + + [Test] + public void should_return_true_if_episode_info_is_not_daily() + { + ValidateParsedEpisodeInfo.ValidateForSeriesType(_parsedEpisodeInfo, _series).Should().BeTrue(); + } + + [Test] + public void should_return_true_if_episode_info_is_daily_for_daily_series() + { + GivenDailyParsedEpisodeInfo(); + GivenDailySeries(); + + ValidateParsedEpisodeInfo.ValidateForSeriesType(_parsedEpisodeInfo, _series).Should().BeTrue(); + } + + [Test] + public void should_return_false_if_episode_info_is_daily_for_standard_series() + { + GivenDailyParsedEpisodeInfo(); + + ValidateParsedEpisodeInfo.ValidateForSeriesType(_parsedEpisodeInfo, _series).Should().BeFalse(); + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_not_log_warning_if_warnIfInvalid_is_false() + { + GivenDailyParsedEpisodeInfo(); + + ValidateParsedEpisodeInfo.ValidateForSeriesType(_parsedEpisodeInfo, _series, false); + ExceptionVerification.ExpectedWarns(0); + } + } +} diff --git a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs index 4f799fa7d..e621801e8 100644 --- a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs @@ -41,15 +41,20 @@ namespace NzbDrone.Core.Test.Profiles [Test] public void should_not_be_able_to_delete_profile_if_assigned_to_series() { + var profile = Builder.CreateNew() + .With(p => p.Id = 2) + .Build(); + var seriesList = Builder.CreateListOfSize(3) .Random(1) - .With(c => c.ProfileId = 2) + .With(c => c.ProfileId = profile.Id) .Build().ToList(); Mocker.GetMock().Setup(c => c.GetAllSeries()).Returns(seriesList); + Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); - Assert.Throws(() => Subject.Delete(2)); + Assert.Throws(() => Subject.Delete(profile.Id)); Mocker.GetMock().Verify(c => c.Delete(It.IsAny()), Times.Never()); @@ -72,4 +77,4 @@ namespace NzbDrone.Core.Test.Profiles Mocker.GetMock().Verify(c => c.Delete(1), Times.Once()); } } -} \ No newline at end of file +} 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..70108d1be 100644 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs @@ -71,7 +71,7 @@ namespace NzbDrone.Core.Test.TvTests.EpisodeMonitoredServiceTests Subject.SetEpisodeMonitoredStatus(_series, null); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Once()); + .Verify(v => v.UpdateSeries(It.IsAny(), It.IsAny()), Times.Once()); Mocker.GetMock() .Verify(v => v.UpdateEpisodes(It.IsAny>()), Times.Never()); @@ -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() @@ -209,13 +249,13 @@ namespace NzbDrone.Core.Test.TvTests.EpisodeMonitoredServiceTests private void VerifySeasonMonitored(Func predicate) { Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Where(predicate).All(n => n.Monitored)))); + .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Where(predicate).All(n => n.Monitored)), It.IsAny())); } private void VerifySeasonNotMonitored(Func predicate) { Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Where(predicate).All(n => !n.Monitored)))); + .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Where(predicate).All(n => !n.Monitored)), It.IsAny())); } } } diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeServiceTests/FindEpisodeByTitleFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeServiceTests/FindEpisodeByTitleFixture.cs index 46fafec3c..77f804383 100644 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeServiceTests/FindEpisodeByTitleFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/EpisodeServiceTests/FindEpisodeByTitleFixture.cs @@ -67,5 +67,31 @@ namespace NzbDrone.Core.Test.TvTests.EpisodeServiceTests .Should() .BeNull(); } + + [Test] + public void should_handle_e00_specials() + { + const string expectedTitle = "Inside The Walking Dead: Walker University"; + GivenEpisodesWithTitles("Inside The Walking Dead", expectedTitle, "Inside The Walking Dead Walker University 2"); + + Subject.FindEpisodeByTitle(1, 1, "The.Walking.Dead.S04E00.Inside.The.Walking.Dead.Walker.University.720p.HDTV.x264-W4F") + .Title + .Should() + .Be(expectedTitle); + } + + [TestCase("Dead.Man.Walking.S04E00.Inside.The.Walking.Dead.Walker.University.720p.HDTV.x264-W4F", "Inside The Walking Dead: Walker University", new[] { "Inside The Walking Dead", "Inside The Walking Dead Walker University 2" })] + [TestCase("Who.1999.S11E00.Twice.Upon.A.Time.1080p.AMZN.WEB-DL.DDP5.1.H.264-NTb", "Twice Upon A Time", new[] { "Last Christmas" })] + [TestCase("Who.1999.S11E00.Twice.Upon.A.Time.Christmas.Special.720p.HDTV.x264-FoV", "Twice Upon A Time", new[] { "Last Christmas" })] + [TestCase("Who.1999.S10E00.Christmas.Special.The.Return.Of.Doctor.Mysterio.1080p.BluRay.x264-OUIJA", "The Return Of Doctor Mysterio", new[] { "Doctor Mysterio" })] + public void should_handle_special(string releaseTitle, string expectedTitle, string[] rejectedTitles) + { + GivenEpisodesWithTitles(rejectedTitles.Concat(new[] { expectedTitle }).ToArray()); + + var episode = Subject.FindEpisodeByTitle(1, 0, releaseTitle); + + episode.Should().NotBeNull(); + episode.Title.Should().Be(expectedTitle); + } } } diff --git a/src/NzbDrone.Core.Test/TvTests/MoveSeriesServiceFixture.cs b/src/NzbDrone.Core.Test/TvTests/MoveSeriesServiceFixture.cs index 528816c99..fcf92117d 100644 --- a/src/NzbDrone.Core.Test/TvTests/MoveSeriesServiceFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/MoveSeriesServiceFixture.cs @@ -63,7 +63,7 @@ namespace NzbDrone.Core.Test.TvTests ExceptionVerification.ExpectedErrors(1); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Never()); + .Verify(v => v.UpdateSeries(It.IsAny(), It.IsAny()), Times.Never()); } [Test] @@ -81,7 +81,7 @@ namespace NzbDrone.Core.Test.TvTests Subject.Execute(_command); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Path == expectedPath)), Times.Once()); + .Verify(v => v.UpdateSeries(It.Is(s => s.Path == expectedPath), It.IsAny()), Times.Once()); } [Test] @@ -90,7 +90,7 @@ namespace NzbDrone.Core.Test.TvTests Subject.Execute(_command); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Path == _command.DestinationPath)), Times.Once()); + .Verify(v => v.UpdateSeries(It.Is(s => s.Path == _command.DestinationPath), It.IsAny()), Times.Once()); Mocker.GetMock() .Verify(v => v.GetSeriesFolder(It.IsAny(), null), Times.Never()); diff --git a/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs b/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs index f441496cd..31c402927 100644 --- a/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs @@ -1,11 +1,14 @@ -using System; +using System; using System.Collections.Generic; +using System.IO; using System.Linq; using FizzWare.NBuilder; +using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Extensions; using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MetadataSource; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; @@ -62,7 +65,7 @@ namespace NzbDrone.Core.Test.TvTests Subject.Execute(new RefreshSeriesCommand(_series.Id)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Count == 2 && s.Seasons.Single(season => season.SeasonNumber == 2).Monitored == true))); + .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Count == 2 && s.Seasons.Single(season => season.SeasonNumber == 2).Monitored == true), It.IsAny())); } [Test] @@ -78,7 +81,7 @@ namespace NzbDrone.Core.Test.TvTests Subject.Execute(new RefreshSeriesCommand(_series.Id)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Count == 2 && s.Seasons.Single(season => season.SeasonNumber == 0).Monitored == false))); + .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Count == 2 && s.Seasons.Single(season => season.SeasonNumber == 0).Monitored == false), It.IsAny())); } [Test] @@ -92,7 +95,7 @@ namespace NzbDrone.Core.Test.TvTests Subject.Execute(new RefreshSeriesCommand(_series.Id)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.TvRageId == newSeriesInfo.TvRageId))); + .Verify(v => v.UpdateSeries(It.Is(s => s.TvRageId == newSeriesInfo.TvRageId), It.IsAny())); } [Test] @@ -106,7 +109,7 @@ namespace NzbDrone.Core.Test.TvTests Subject.Execute(new RefreshSeriesCommand(_series.Id)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.TvMazeId == newSeriesInfo.TvMazeId))); + .Verify(v => v.UpdateSeries(It.Is(s => s.TvMazeId == newSeriesInfo.TvMazeId), It.IsAny())); } [Test] @@ -115,7 +118,7 @@ namespace NzbDrone.Core.Test.TvTests Subject.Execute(new RefreshSeriesCommand(_series.Id)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Never()); + .Verify(v => v.UpdateSeries(It.IsAny(), It.IsAny()), Times.Never()); ExceptionVerification.ExpectedErrors(1); } @@ -131,7 +134,7 @@ namespace NzbDrone.Core.Test.TvTests Subject.Execute(new RefreshSeriesCommand(_series.Id)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.TvdbId == newSeriesInfo.TvdbId))); + .Verify(v => v.UpdateSeries(It.Is(s => s.TvdbId == newSeriesInfo.TvdbId), It.IsAny())); ExceptionVerification.ExpectedWarns(1); } @@ -157,7 +160,7 @@ namespace NzbDrone.Core.Test.TvTests Subject.Execute(new RefreshSeriesCommand(_series.Id)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Count == 2))); + .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Count == 2), It.IsAny())); } [Test] @@ -177,8 +180,38 @@ namespace NzbDrone.Core.Test.TvTests Subject.Execute(new RefreshSeriesCommand(_series.Id)); Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Count == 2))); + .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Count == 2), It.IsAny())); } + + [Test] + public void should_rescan_series_if_updating_fails() + { + Mocker.GetMock() + .Setup(s => s.GetSeriesInfo(_series.Id)) + .Throws(new IOException()); + + Assert.Throws(() => Subject.Execute(new RefreshSeriesCommand(_series.Id))); + + Mocker.GetMock() + .Verify(v => v.Scan(_series), Times.Once()); + + ExceptionVerification.ExpectedErrors(1); + } + + [Test] + public void should_not_rescan_series_if_updating_fails_with_series_not_found() + { + Mocker.GetMock() + .Setup(s => s.GetSeriesInfo(_series.Id)) + .Throws(new SeriesNotFoundException(_series.Id)); + + Subject.Execute(new RefreshSeriesCommand(_series.Id)); + + Mocker.GetMock() + .Verify(v => v.Scan(_series), Times.Never()); + + ExceptionVerification.ExpectedErrors(1); + } } } diff --git a/src/NzbDrone.Core.Test/TvTests/SeriesTitleNormalizerFixture.cs b/src/NzbDrone.Core.Test/TvTests/SeriesTitleNormalizerFixture.cs index 4355f77e0..148690091 100644 --- a/src/NzbDrone.Core.Test/TvTests/SeriesTitleNormalizerFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/SeriesTitleNormalizerFixture.cs @@ -8,7 +8,8 @@ namespace NzbDrone.Core.Test.TvTests public class SeriesTitleNormalizerFixture { [TestCase("A to Z", 281588, "a to z")] - [TestCase("A. D. - The Trials & Triumph of the Early Church", 266757, "ad trials triumph early church")] + [TestCase("A.D. The Bible Continues", 289260, "ad bible continues")] + [TestCase("A.P. Bio", 328534, "ap bio")] public void should_use_precomputed_title(string title, int tvdbId, string expected) { SeriesTitleNormalizer.Normalize(title, tvdbId).Should().Be(expected); 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.Test/TvTests/ShouldRefreshSeriesFixture.cs b/src/NzbDrone.Core.Test/TvTests/ShouldRefreshSeriesFixture.cs index 6fb44c09a..cbbaf0146 100644 --- a/src/NzbDrone.Core.Test/TvTests/ShouldRefreshSeriesFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/ShouldRefreshSeriesFixture.cs @@ -51,7 +51,7 @@ namespace NzbDrone.Core.Test.TvTests private void GivenSeriesLastRefreshedRecently() { - _series.LastInfoSync = DateTime.UtcNow.AddHours(-1); + _series.LastInfoSync = DateTime.UtcNow.AddMinutes(-30); } private void GivenRecentlyAired() diff --git a/src/NzbDrone.Core.Test/ValidationTests/SystemFolderValidatorFixture.cs b/src/NzbDrone.Core.Test/ValidationTests/SystemFolderValidatorFixture.cs new file mode 100644 index 000000000..39a7c68e4 --- /dev/null +++ b/src/NzbDrone.Core.Test/ValidationTests/SystemFolderValidatorFixture.cs @@ -0,0 +1,74 @@ +using System; +using System.IO; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Validation.Paths; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.ValidationTests +{ + public class SystemFolderValidatorFixture : CoreTest + { + private TestValidator _validator; + + [SetUp] + public void Setup() + { + _validator = new TestValidator + { + v => v.RuleFor(s => s.Path).SetValidator(Subject) + }; + } + + [Test] + public void should_not_be_valid_if_set_to_windows_folder() + { + WindowsOnly(); + + var series = Builder.CreateNew() + .With(s => s.Path = Environment.GetFolderPath(Environment.SpecialFolder.Windows)) + .Build(); + + _validator.Validate(series).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_child_of_windows_folder() + { + WindowsOnly(); + + var series = Builder.CreateNew() + .With(s => s.Path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Test")) + .Build(); + + _validator.Validate(series).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_set_to_bin_folder() + { + MonoOnly(); + + var series = Builder.CreateNew() + .With(s => s.Path = "/bin") + .Build(); + + _validator.Validate(series).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_child_of_bin_folder() + { + MonoOnly(); + + var series = Builder.CreateNew() + .With(s => s.Path = "/bin/test") + .Build(); + + _validator.Validate(series).IsValid.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/packages.config b/src/NzbDrone.Core.Test/packages.config index af453f010..732b2e721 100644 --- a/src/NzbDrone.Core.Test/packages.config +++ b/src/NzbDrone.Core.Test/packages.config @@ -6,10 +6,10 @@ - + - + diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index 85b9b044c..97927d442 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -12,6 +12,7 @@ namespace NzbDrone.Core.Annotations public int Order { get; private set; } public string Label { get; set; } + public string Unit { get; set; } public string HelpText { get; set; } public string HelpLink { get; set; } public FieldType Type { get; set; } @@ -33,4 +34,4 @@ namespace NzbDrone.Core.Annotations Url, Captcha } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Backup/Backup.cs b/src/NzbDrone.Core/Backup/Backup.cs index a4505d991..5d148648e 100644 --- a/src/NzbDrone.Core/Backup/Backup.cs +++ b/src/NzbDrone.Core/Backup/Backup.cs @@ -4,7 +4,7 @@ namespace NzbDrone.Core.Backup { public class Backup { - public string Path { get; set; } + public string Name { get; set; } public BackupType Type { get; set; } public DateTime Time { get; set; } } diff --git a/src/NzbDrone.Core/Backup/BackupCommand.cs b/src/NzbDrone.Core/Backup/BackupCommand.cs index 3a852cf7a..1f5550e59 100644 --- a/src/NzbDrone.Core/Backup/BackupCommand.cs +++ b/src/NzbDrone.Core/Backup/BackupCommand.cs @@ -4,7 +4,18 @@ namespace NzbDrone.Core.Backup { public class BackupCommand : Command { - public BackupType Type { get; set; } + public BackupType Type + { + get + { + if (Trigger == CommandTrigger.Scheduled) + { + return BackupType.Scheduled; + } + + return BackupType.Manual; + } + } public override bool SendUpdatesToClient => true; diff --git a/src/NzbDrone.Core/Backup/BackupService.cs b/src/NzbDrone.Core/Backup/BackupService.cs index 8cc89d87b..3bd564b2b 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(); @@ -89,7 +93,7 @@ namespace NzbDrone.Core.Backup { backups.AddRange(GetBackupFiles(folder).Select(b => new Backup { - Path = Path.GetFileName(b), + Name = Path.GetFileName(b), Type = backupType, Time = _diskProvider.FileGetLastWrite(b) })); @@ -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/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index d9b57d68a..d85151f6d 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -106,6 +106,12 @@ namespace NzbDrone.Core.Configuration set { SetValue("RssSyncInterval", value); } } + public int MaximumSize + { + get { return GetValueInt("MaximumSize", 0); } + set { SetValue("MaximumSize", value); } + } + public int MinimumAge { get { return GetValueInt("MinimumAge", 0); } @@ -155,6 +161,13 @@ namespace NzbDrone.Core.Configuration set { SetValue("CreateEmptySeriesFolders", value); } } + public bool DeleteEmptyFolders + { + get { return GetValueBoolean("DeleteEmptyFolders", false); } + + set { SetValue("DeleteEmptyFolders", value); } + } + public FileDateType FileDate { get { return GetValueEnum("FileDate", FileDateType.None); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 589e003e1..7b4080148 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Core.MediaFiles; using NzbDrone.Common.Http.Proxy; @@ -28,6 +28,7 @@ namespace NzbDrone.Core.Configuration string RecycleBin { get; set; } bool AutoDownloadPropers { get; set; } bool CreateEmptySeriesFolders { get; set; } + bool DeleteEmptyFolders { get; set; } FileDateType FileDate { get; set; } bool SkipFreeSpaceCheckWhenImporting { get; set; } bool CopyUsingHardlinks { get; set; } @@ -45,6 +46,7 @@ namespace NzbDrone.Core.Configuration //Indexers int Retention { get; set; } int RssSyncInterval { get; set; } + int MaximumSize { get; set; } int MinimumAge { get; set; } //UI 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/Converters/GuidConverter.cs b/src/NzbDrone.Core/Datastore/Converters/GuidConverter.cs index b2bf33526..5cab866b2 100644 --- a/src/NzbDrone.Core/Datastore/Converters/GuidConverter.cs +++ b/src/NzbDrone.Core/Datastore/Converters/GuidConverter.cs @@ -1,4 +1,4 @@ -using System; +using System; using Marr.Data.Converters; using Marr.Data.Mapping; @@ -25,6 +25,11 @@ namespace NzbDrone.Core.Datastore.Converters public object ToDB(object clrValue) { + if (clrValue == null) + { + return DBNull.Value; + } + var value = clrValue; return value.ToString(); diff --git a/src/NzbDrone.Core/Datastore/Converters/Int32Converter.cs b/src/NzbDrone.Core/Datastore/Converters/Int32Converter.cs index c96586aa7..443cd1767 100644 --- a/src/NzbDrone.Core/Datastore/Converters/Int32Converter.cs +++ b/src/NzbDrone.Core/Datastore/Converters/Int32Converter.cs @@ -1,4 +1,4 @@ -using System; +using System; using Marr.Data.Converters; using Marr.Data.Mapping; @@ -23,17 +23,7 @@ namespace NzbDrone.Core.Datastore.Converters public object FromDB(ColumnMap map, object dbValue) { - if (dbValue == DBNull.Value) - { - return DBNull.Value; - } - - if (dbValue is int) - { - return dbValue; - } - - return Convert.ToInt32(dbValue); + return FromDB(new ConverterContext { ColumnMap = map, DbValue = dbValue }); } public object ToDB(object clrValue) @@ -43,4 +33,4 @@ namespace NzbDrone.Core.Datastore.Converters public Type DbType { get; private set; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Datastore/Converters/QualityIntConverter.cs b/src/NzbDrone.Core/Datastore/Converters/QualityIntConverter.cs index 254dde15e..0b225a8a2 100644 --- a/src/NzbDrone.Core/Datastore/Converters/QualityIntConverter.cs +++ b/src/NzbDrone.Core/Datastore/Converters/QualityIntConverter.cs @@ -1,4 +1,4 @@ -using System; +using System; using Marr.Data.Converters; using Marr.Data.Mapping; using NzbDrone.Core.Qualities; @@ -27,9 +27,9 @@ namespace NzbDrone.Core.Datastore.Converters public object ToDB(object clrValue) { - if(clrValue == DBNull.Value) return 0; + if (clrValue == DBNull.Value) return 0; - if(clrValue as Quality == null) + if (clrValue as Quality == null) { throw new InvalidOperationException("Attempted to save a quality that isn't really a quality"); } @@ -56,4 +56,4 @@ namespace NzbDrone.Core.Datastore.Converters writer.WriteValue(ToDB(value)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Datastore/Converters/TimeSpanConverter.cs b/src/NzbDrone.Core/Datastore/Converters/TimeSpanConverter.cs index 9ea6b398f..f8080fdff 100644 --- a/src/NzbDrone.Core/Datastore/Converters/TimeSpanConverter.cs +++ b/src/NzbDrone.Core/Datastore/Converters/TimeSpanConverter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; using Marr.Data.Converters; using Marr.Data.Mapping; @@ -15,22 +15,17 @@ namespace NzbDrone.Core.Datastore.Converters return TimeSpan.Zero; } - return TimeSpan.Parse(context.DbValue.ToString()); + if (context.DbValue is TimeSpan) + { + return context.DbValue; + } + + return TimeSpan.Parse(context.DbValue.ToString(), CultureInfo.InvariantCulture); } public object FromDB(ColumnMap map, object dbValue) { - if (dbValue == DBNull.Value) - { - return DBNull.Value; - } - - if (dbValue is TimeSpan) - { - return dbValue; - } - - return TimeSpan.Parse(dbValue.ToString(), CultureInfo.InvariantCulture); + return FromDB(new ConverterContext { ColumnMap = map, DbValue = dbValue }); } public object ToDB(object clrValue) @@ -45,4 +40,4 @@ namespace NzbDrone.Core.Datastore.Converters public Type DbType { get; private set; } } -} \ No newline at end of file +} 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/DbFactory.cs b/src/NzbDrone.Core/Datastore/DbFactory.cs index d2a239d6d..e49df1c21 100644 --- a/src/NzbDrone.Core/Datastore/DbFactory.cs +++ b/src/NzbDrone.Core/Datastore/DbFactory.cs @@ -27,10 +27,20 @@ namespace NzbDrone.Core.Datastore static DbFactory() { + InitializeEnvironment(); + MapRepository.Instance.ReflectionStrategy = new SimpleReflectionStrategy(); TableMapping.Map(); } + private static void InitializeEnvironment() + { + // Speed up sqlite3 initialization since we don't use the config file and can't rely on preloading. + Environment.SetEnvironmentVariable("No_Expand", "true"); + Environment.SetEnvironmentVariable("No_SQLiteXmlConfigFile", "true"); + Environment.SetEnvironmentVariable("No_PreLoadSQLite", "true"); + } + public static void RegisterDatabase(IContainer container) { var mainDb = new MainDatabase(container.Resolve().Create()); 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/118_add_history_eventType_index.cs b/src/NzbDrone.Core/Datastore/Migration/118_add_history_eventType_index.cs new file mode 100644 index 000000000..15f73b167 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/118_add_history_eventType_index.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(118)] + public class add_history_eventType_index : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.Index().OnTable("History").OnColumn("EventType"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/120_update_series_episodes_history_indexes.cs b/src/NzbDrone.Core/Datastore/Migration/120_update_series_episodes_history_indexes.cs new file mode 100644 index 000000000..9028b38df --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/120_update_series_episodes_history_indexes.cs @@ -0,0 +1,23 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(120)] + public class update_series_episodes_history_indexes : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.Index().OnTable("Episodes").OnColumn("SeriesId").Ascending() + .OnColumn("AirDate").Ascending(); + + Delete.Index().OnTable("History").OnColumn("EpisodeId"); + Create.Index().OnTable("History").OnColumn("EpisodeId").Ascending() + .OnColumn("Date").Descending(); + + Delete.Index().OnTable("History").OnColumn("DownloadId"); + Create.Index().OnTable("History").OnColumn("DownloadId").Ascending() + .OnColumn("Date").Descending(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/121_update_animetosho_url.cs b/src/NzbDrone.Core/Datastore/Migration/121_update_animetosho_url.cs new file mode 100644 index 000000000..1a91ded75 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/121_update_animetosho_url.cs @@ -0,0 +1,22 @@ +using FluentMigrator; +using Newtonsoft.Json.Linq; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(121)] + public class update_animetosho_url : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.Sql("UPDATE Indexers SET Settings = Replace(Replace(Settings, '//animetosho.org', '//feed.animetosho.org'), '/feed/nabapi', '/nabapi') WHERE (Implementation = 'Newznab' OR Implementation = 'Torznab') AND Settings LIKE '%animetosho%';"); + } + } + + public class NewznabSettings121 + { + public string BaseUrl { get; set; } + + public string ApiPath { get; set; } + } +} 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..cea8b2898 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Marr.Data; using Marr.Data.Mapping; @@ -46,7 +46,11 @@ namespace NzbDrone.Core.Datastore RegisterMappers(); Mapper.Entity().RegisterModel("Config"); - Mapper.Entity().RegisterModel("RootFolders").Ignore(r => r.FreeSpace); + + Mapper.Entity().RegisterModel("RootFolders") + .Ignore(r => r.FreeSpace) + .Ignore(r => r.TotalSpace); + Mapper.Entity().RegisterModel("ScheduledTasks"); Mapper.Entity().RegisterDefinition("Indexers") @@ -60,7 +64,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 +84,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 +120,7 @@ namespace NzbDrone.Core.Datastore .Ignore(c => c.Message); Mapper.Entity().RegisterModel("IndexerStatus"); + Mapper.Entity().RegisterModel("DownloadClientStatus"); } private static void RegisterMappers() @@ -171,4 +176,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..eb583499e 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -58,6 +58,7 @@ namespace NzbDrone.Core.DecisionEngine { DownloadDecision decision = null; _logger.ProgressTrace("Processing release {0}/{1}", reportNumber, reports.Count); + _logger.Debug("Processing release '{0}' from '{1}'", report.Title, report.Indexer); try { @@ -65,7 +66,7 @@ namespace NzbDrone.Core.DecisionEngine if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode) { - var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(report.Title, report.TvdbId, report.TvRageId, searchCriteria); + var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(parsedEpisodeInfo, report.Title, report.TvdbId, report.TvRageId, searchCriteria); if (specialEpisodeInfo != null) { @@ -122,8 +123,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..efcb64b62 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) @@ -70,7 +71,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications //Multiply maxSize by Series.Runtime maxSize = maxSize * subject.Series.Runtime * subject.Episodes.Count; - if (subject.Episodes.Count == 1) + if (subject.Episodes.Count == 1 && subject.Series.SeriesType == SeriesTypes.Standard) { Episode episode = subject.Episodes.First(); List seasonEpisodes; @@ -78,7 +79,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications var seasonSearchCriteria = searchCriteria as SeasonSearchCriteria; if (seasonSearchCriteria != null && !seasonSearchCriteria.Series.UseSceneNumbering && seasonSearchCriteria.Episodes.Any(v => v.Id == episode.Id)) { - seasonEpisodes = (searchCriteria as SeasonSearchCriteria).Episodes; + seasonEpisodes = seasonSearchCriteria.Episodes; } else { 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/MaximumSizeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/MaximumSizeSpecification.cs new file mode 100644 index 000000000..a990a00ed --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/MaximumSizeSpecification.cs @@ -0,0 +1,53 @@ +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class MaximumSizeSpecification : IDecisionEngineSpecification + { + private readonly IConfigService _configService; + private readonly Logger _logger; + + public MaximumSizeSpecification(IConfigService configService, Logger logger) + { + _configService = configService; + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + { + var size = subject.Release.Size; + var maximumSize = _configService.MaximumSize.Megabytes(); + + if (maximumSize == 0) + { + _logger.Debug("Maximum size is not set."); + return Decision.Accept(); + } + + if (size == 0) + { + _logger.Debug("Release has unknown size, skipping size check."); + return Decision.Accept(); + } + + _logger.Debug("Checking if release meets maximum size requirements. {0}", size.SizeSuffix()); + + if (size > maximumSize) + { + var message = $"{size.SizeSuffix()} is too big, maximum size is {maximumSize.SizeSuffix()}"; + + _logger.Debug(message); + return Decision.Reject(message); + } + + return Decision.Accept(); + } + } +} 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..4db41230a 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs @@ -11,15 +11,18 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { public class ReleaseRestrictionsSpecification : IDecisionEngineSpecification { - private readonly IRestrictionService _restrictionService; private readonly Logger _logger; + private readonly IRestrictionService _restrictionService; + private readonly ITermMatcher _termMatcher; - public ReleaseRestrictionsSpecification(IRestrictionService restrictionService, Logger logger) + public ReleaseRestrictionsSpecification(ITermMatcher termMatcher, IRestrictionService restrictionService, Logger logger) { - _restrictionService = restrictionService; _logger = logger; + _restrictionService = restrictionService; + _termMatcher = termMatcher; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) @@ -62,9 +65,9 @@ namespace NzbDrone.Core.DecisionEngine.Specifications return Decision.Accept(); } - private static List ContainsAny(List terms, string title) + private List ContainsAny(List terms, string title) { - return terms.Where(t => title.ToLowerInvariant().Contains(t.ToLowerInvariant())).ToList(); + return terms.Where(t => _termMatcher.IsMatch(t, title)).ToList(); } } } 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..254eceb91 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) @@ -80,7 +81,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync var episodeIds = subject.Episodes.Select(e => e.Id); - var oldest = _pendingReleaseService.OldestPendingRelease(subject.Series.Id, episodeIds); + var oldest = _pendingReleaseService.OldestPendingRelease(subject.Series.Id, episodeIds.ToArray()); if (oldest != null && oldest.Release.AgeMinutes > delay) { 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 deleted file mode 100644 index 50fd9b3cc..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/DailyEpisodeMatchSpecification.cs +++ /dev/null @@ -1,43 +0,0 @@ -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.DecisionEngine.Specifications.Search -{ - public class DailyEpisodeMatchSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - private readonly IEpisodeService _episodeService; - - public DailyEpisodeMatchSpecification(Logger logger, IEpisodeService episodeService) - { - _logger = logger; - _episodeService = episodeService; - } - - public RejectionType Type => RejectionType.Permanent; - - public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) - { - if (searchCriteria == null) - { - return Decision.Accept(); - } - - var dailySearchSpec = searchCriteria as DailyEpisodeSearchCriteria; - - if (dailySearchSpec == null) return Decision.Accept(); - - var episode = _episodeService.GetEpisode(dailySearchSpec.Series.Id, dailySearchSpec.AirDate.ToString(Episode.AIR_DATE_FORMAT)); - - if (!remoteEpisode.ParsedEpisodeInfo.IsDaily || remoteEpisode.ParsedEpisodeInfo.AirDate != episode.AirDate) - { - _logger.Debug("Episode AirDate does not match searched episode number, skipping."); - return Decision.Reject("Episode does not match"); - } - - 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..18c59a061 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) @@ -30,7 +31,25 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search if (!criteriaEpisodes.Intersect(remoteEpisodes).Any()) { _logger.Debug("Release rejected since the episode wasn't requested: {0}", remoteEpisode.ParsedEpisodeInfo); - return Decision.Reject("Episode wasn't requested"); + + if (remoteEpisodes.Any()) + { + var episodes = remoteEpisode.Episodes.OrderBy(v => v.SeasonNumber).ThenBy(v => v.EpisodeNumber).ToList(); + + if (episodes.Count > 1) + { + return Decision.Reject($"Episode wasn't requested: {episodes.First().SeasonNumber}x{episodes.First().EpisodeNumber}-{episodes.Last().EpisodeNumber}"); + } + else + { + return Decision.Reject($"Episode wasn't requested: {episodes.First().SeasonNumber}x{episodes.First().EpisodeNumber}"); + } + } + else + { + return Decision.Reject("Episode wasn't requested"); + } + } return Decision.Accept(); 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..2f1c341e4 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) @@ -24,8 +25,16 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search } var singleEpisodeSpec = searchCriteria as SingleEpisodeSearchCriteria; - if (singleEpisodeSpec == null) return Decision.Accept(); + if (singleEpisodeSpec != null) return IsSatisfiedBy(remoteEpisode, singleEpisodeSpec); + var animeEpisodeSpec = searchCriteria as AnimeEpisodeSearchCriteria; + if (animeEpisodeSpec != null) return IsSatisfiedBy(remoteEpisode, animeEpisodeSpec); + + return Decision.Accept(); + } + + private Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SingleEpisodeSearchCriteria singleEpisodeSpec) + { if (singleEpisodeSpec.SeasonNumber != remoteEpisode.ParsedEpisodeInfo.SeasonNumber) { _logger.Debug("Season number does not match searched season number, skipping."); @@ -46,5 +55,16 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search return Decision.Accept(); } + + private Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, AnimeEpisodeSearchCriteria singleEpisodeSpec) + { + if (remoteEpisode.ParsedEpisodeInfo.FullSeason) + { + _logger.Debug("Full season result during single episode search, skipping."); + return Decision.Reject("Full season pack"); + } + + 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..7caabeed5 --- /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 +{ + 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/DiskSpace/DiskSpaceService.cs b/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs index edc641533..5c8b63e44 100644 --- a/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs +++ b/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs @@ -1,7 +1,8 @@ using System; -using System.IO; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text.RegularExpressions; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; @@ -22,6 +23,8 @@ namespace NzbDrone.Core.DiskSpace private readonly IDiskProvider _diskProvider; private readonly Logger _logger; + private static readonly Regex _regexSpecialDrive = new Regex("^/var/lib/(docker|rancher|kubelet)(/|$)|^/boot(/|$)|/docker(/var)?/aufs(/|$)", RegexOptions.Compiled); + public DiskSpaceService(ISeriesService seriesService, IConfigService configService, IDiskProvider diskProvider, Logger logger) { _seriesService = seriesService; @@ -32,37 +35,37 @@ namespace NzbDrone.Core.DiskSpace public List GetFreeSpace() { - var diskSpace = new List(); - diskSpace.AddRange(GetSeriesFreeSpace()); - diskSpace.AddRange(GetDroneFactoryFreeSpace()); - diskSpace.AddRange(GetFixedDisksFreeSpace()); + var importantRootFolders = GetSeriesRootPaths().Concat(GetDroneFactoryRootPaths()).Distinct().ToList(); - return diskSpace.DistinctBy(d => d.Path).ToList(); + var optionalRootFolders = GetFixedDisksRootPaths().Except(importantRootFolders).Distinct().ToList(); + + var diskSpace = GetDiskSpace(importantRootFolders).Concat(GetDiskSpace(optionalRootFolders, true)).ToList(); + + return diskSpace; } - private IEnumerable GetSeriesFreeSpace() + private IEnumerable GetSeriesRootPaths() { - var seriesRootPaths = _seriesService.GetAllSeries() + return _seriesService.GetAllSeries() .Where(s => _diskProvider.FolderExists(s.Path)) .Select(s => _diskProvider.GetPathRoot(s.Path)) .Distinct(); - - return GetDiskSpace(seriesRootPaths); } - private IEnumerable GetDroneFactoryFreeSpace() + private IEnumerable GetDroneFactoryRootPaths() { if (_configService.DownloadedEpisodesFolder.IsNotNullOrWhiteSpace() && _diskProvider.FolderExists(_configService.DownloadedEpisodesFolder)) { - return GetDiskSpace(new[] { _diskProvider.GetPathRoot(_configService.DownloadedEpisodesFolder) }); + yield return _configService.DownloadedEpisodesFolder; } - - return new List(); } - private IEnumerable GetFixedDisksFreeSpace() + private IEnumerable GetFixedDisksRootPaths() { - return GetDiskSpace(_diskProvider.GetMounts().Where(d => d.DriveType == DriveType.Fixed).Select(d => d.RootDirectory), true); + return _diskProvider.GetMounts() + .Where(d => d.DriveType == DriveType.Fixed) + .Where(d => !_regexSpecialDrive.IsMatch(d.RootDirectory)) + .Select(d => d.RootDirectory); } private IEnumerable GetDiskSpace(IEnumerable paths, bool suppressWarnings = false) 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..4b2c7146a 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text; @@ -82,9 +82,6 @@ namespace NzbDrone.Core.Download.Clients.Blackhole public override string Name => "Torrent Blackhole"; - public override ProviderMessage Message => new ProviderMessage("Magnet links are not supported.", ProviderMessageType.Warning); - - public override IEnumerable GetItems() { foreach (var item in _scanWatchFolder.GetItems(Settings.WatchFolder, ScanGracePeriod)) @@ -103,7 +100,8 @@ namespace NzbDrone.Core.Download.Clients.Blackhole Status = item.Status, - IsReadOnly = Settings.ReadOnly + CanMoveFiles = !Settings.ReadOnly, + CanBeRemoved = !Settings.ReadOnly }; } } @@ -118,9 +116,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..561b4901b 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using FluentValidation.Results; @@ -24,8 +24,9 @@ namespace NzbDrone.Core.Download.Clients.Blackhole IConfigService configService, IDiskProvider diskProvider, IRemotePathMappingService remotePathMappingService, + IValidateNzbs nzbValidationService, Logger logger) - : base(httpClient, configService, diskProvider, remotePathMappingService, logger) + : base(httpClient, configService, diskProvider, remotePathMappingService, nzbValidationService, logger) { _scanWatchFolder = scanWatchFolder; @@ -68,7 +69,10 @@ namespace NzbDrone.Core.Download.Clients.Blackhole OutputPath = item.OutputPath, - Status = item.Status + Status = item.Status, + + CanBeRemoved = true, + CanMoveFiles = true }; } } @@ -83,9 +87,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..54848872b 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using NzbDrone.Common.Disk; @@ -35,12 +35,17 @@ namespace NzbDrone.Core.Download.Clients.Deluge { var actualHash = _proxy.AddTorrentFromMagnet(magnetLink, Settings); + if (actualHash.IsNullOrWhiteSpace()) + { + throw new DownloadClientException("Deluge failed to add magnet " + magnetLink); + } + if (!Settings.TvCategory.IsNullOrWhiteSpace()) { _proxy.SetLabel(actualHash, Settings.TvCategory, Settings); } - _proxy.SetTorrentConfiguration(actualHash, "remove_at_ratio", false, Settings); + _proxy.SetTorrentSeedingConfiguration(actualHash, remoteEpisode.SeedConfiguration, Settings); var isRecentEpisode = remoteEpisode.IsRecentEpisode(); @@ -57,13 +62,18 @@ 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); + } + + _proxy.SetTorrentSeedingConfiguration(actualHash, remoteEpisode.SeedConfiguration, Settings); + if (!Settings.TvCategory.IsNullOrWhiteSpace()) { _proxy.SetLabel(actualHash, Settings.TvCategory, Settings); } - _proxy.SetTorrentConfiguration(actualHash, "remove_at_ratio", false, Settings); - var isRecentEpisode = remoteEpisode.IsRecentEpisode(); if (isRecentEpisode && Settings.RecentTvPriority == (int)DelugePriority.First || @@ -81,21 +91,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(); @@ -112,7 +114,18 @@ namespace NzbDrone.Core.Download.Clients.Deluge var outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.DownloadPath)); item.OutputPath = outputPath + torrent.Name; item.RemainingSize = torrent.Size - torrent.BytesDownloaded; - item.RemainingTime = TimeSpan.FromSeconds(torrent.Eta); + item.SeedRatio = torrent.Ratio; + + try + { + item.RemainingTime = TimeSpan.FromSeconds(torrent.Eta); + } + catch (OverflowException ex) + { + _logger.Debug(ex, "ETA for {0} is too long: {1}", torrent.Name, torrent.Eta); + item.RemainingTime = TimeSpan.MaxValue; + } + item.TotalSize = torrent.Size; if (torrent.State == DelugeTorrentStatus.Error) @@ -137,15 +150,13 @@ namespace NzbDrone.Core.Download.Clients.Deluge item.Status = DownloadItemStatus.Downloading; } - // 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; - } + // 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. + item.CanMoveFiles = item.CanBeRemoved = + torrent.IsAutoManaged && + torrent.StopAtRatio && + torrent.Ratio >= torrent.StopRatio && + torrent.State == DelugeTorrentStatus.Paused; items.Add(item); } @@ -158,7 +169,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 +180,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 +189,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge { status.OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) }; } - + return status; } @@ -198,12 +209,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 +239,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 +290,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..21a34e2be 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -84,21 +84,33 @@ namespace NzbDrone.Core.Download.Clients.Deluge public string AddTorrentFromMagnet(string magnetLink, DelugeSettings settings) { - var response = ProcessRequest(settings, "core.add_torrent_magnet", magnetLink, new JObject()); + var options = new + { + add_paused = settings.AddPaused, + remove_at_ratio = false + }; + + var response = ProcessRequest(settings, "core.add_torrent_magnet", magnetLink, options); return response; } public string AddTorrentFromFile(string filename, byte[] fileContent, DelugeSettings settings) { - var response = ProcessRequest(settings, "core.add_torrent_file", filename, fileContent, new JObject()); + var options = new + { + add_paused = settings.AddPaused, + remove_at_ratio = false + }; + + var response = ProcessRequest(settings, "core.add_torrent_file", filename, fileContent, options); return response; } - public bool RemoveTorrent(string hashString, bool removeData, DelugeSettings settings) + public bool RemoveTorrent(string hash, bool removeData, DelugeSettings settings) { - var response = ProcessRequest(settings, "core.remove_torrent", hashString, removeData); + var response = ProcessRequest(settings, "core.remove_torrent", hash, removeData); return response; } @@ -139,13 +151,17 @@ namespace NzbDrone.Core.Download.Clients.Deluge public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, DelugeSettings settings) { + if (seedConfiguration == null) return; + + var ratioArguments = new Dictionary(); + if (seedConfiguration.Ratio != null) { - var ratioArguments = new Dictionary(); ratioArguments.Add("stop_ratio", seedConfiguration.Ratio.Value); - - ProcessRequest(settings, "core.set_torrent_options", new string[] { hash }, ratioArguments); + ratioArguments.Add("stop_at_ratio", 1); } + + ProcessRequest(settings, "core.set_torrent_options", new[] { hash }, ratioArguments); } public void AddLabel(string label, DelugeSettings settings) @@ -164,7 +180,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge var requestBuilder = new JsonRpcRequestBuilder(url); requestBuilder.LogResponseContent = true; - + requestBuilder.Resource("json"); requestBuilder.PostProcess += r => r.RequestTimeout = TimeSpan.FromSeconds(15); @@ -231,7 +247,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/Deluge/DelugeSettings.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs index 7d9375570..025b775fd 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -49,7 +49,10 @@ namespace NzbDrone.Core.Download.Clients.Deluge [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] public int OlderTvPriority { get; set; } - [FieldDefinition(7, Label = "Use SSL", Type = FieldType.Checkbox)] + [FieldDefinition(7, Label = "Add Paused", Type = FieldType.Checkbox)] + public bool AddPaused { get; set; } + + [FieldDefinition(8, Label = "Use SSL", Type = FieldType.Checkbox)] public bool UseSsl { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs index 5dcdc7549..898c425b4 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs @@ -13,7 +13,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge [JsonProperty(PropertyName = "is_finished")] public bool IsFinished { get; set; } - + // Other paths: What is the difference between 'move_completed_path' and 'move_on_completed_path'? /* [JsonProperty(PropertyName = "move_completed_path")] @@ -22,7 +22,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge public String DownloadPathMoveOnCompleted { get; set; } */ - [JsonProperty(PropertyName = "save_path")] + [JsonProperty(PropertyName = "save_path")] public string DownloadPath { get; set; } [JsonProperty(PropertyName = "total_size")] 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/DownloadStationTask.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs index a22cc7296..5b4a61253 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs @@ -1,7 +1,6 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; -using Newtonsoft.Json.Converters; +using NzbDrone.Common.Serializer; namespace NzbDrone.Core.Download.Clients.DownloadStation { @@ -23,7 +22,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation [JsonProperty(PropertyName = "status_extra")] public Dictionary StatusExtra { get; set; } - [JsonConverter(typeof(StringEnumConverter))] + [JsonConverter(typeof(UnderscoreStringEnumConverter), DownloadStationTaskStatus.Unknown)] public DownloadStationTaskStatus Status { get; set; } public DownloadStationTaskAdditional Additional { get; set; } @@ -41,6 +40,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation public enum DownloadStationTaskStatus { + Unknown, Waiting, Downloading, Paused, @@ -48,9 +48,10 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation Finished, HashChecking, Seeding, - FileHostingWaiting, + FilehostingWaiting, Extracting, - Error + Error, + CaptchaNeeded } public enum DownloadStationPriority 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/Responses/DiskStationError.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs index d8ce31d71..edb82465b 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs @@ -47,6 +47,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses FileStationMessages = new Dictionary { + { 160, "Permission denied. Give your user access to FileStation."}, { 400, "Invalid parameter of file operation" }, { 401, "Unknown error of file operation" }, { 402, "System is too busy" }, diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs index 911152694..f7611daf4 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs @@ -88,9 +88,11 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation TotalSize = torrent.Size, RemainingSize = GetRemainingSize(torrent), RemainingTime = GetRemainingTime(torrent), + SeedRatio = GetSeedRatio(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 +106,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)) } @@ -120,7 +122,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { _logger.Debug(e, "Failed to get config from Download Station"); - throw e; + throw; } } @@ -199,6 +201,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) @@ -221,7 +228,9 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { switch (torrent.Status) { + case DownloadStationTaskStatus.Unknown: case DownloadStationTaskStatus.Waiting: + case DownloadStationTaskStatus.FilehostingWaiting: return torrent.Size == 0 || GetRemainingSize(torrent) > 0 ? DownloadItemStatus.Queued : DownloadItemStatus.Completed; case DownloadStationTaskStatus.Paused: return DownloadItemStatus.Paused; @@ -270,6 +279,19 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation return TimeSpan.FromSeconds(remainingSize / downloadSpeed); } + protected double? GetSeedRatio(DownloadStationTask torrent) + { + var downloaded = torrent.Additional.Transfer["size_downloaded"].ParseInt64(); + var uploaded = torrent.Additional.Transfer["size_uploaded"].ParseInt64(); + + if (downloaded.HasValue && uploaded.HasValue) + { + return downloaded <= 0 ? 0 : (double)uploaded.Value / downloaded.Value; + } + + return null; + } + protected ValidationFailure TestOutputPath() { try @@ -314,12 +336,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 +362,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 +375,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..632eedcb0 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -32,9 +32,10 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation IConfigService configService, IDiskProvider diskProvider, IRemotePathMappingService remotePathMappingService, + IValidateNzbs nzbValidationService, Logger logger ) - : base(httpClient, configService, diskProvider, remotePathMappingService, logger) + : base(httpClient, configService, diskProvider, remotePathMappingService, nzbValidationService, logger) { _dsInfoProxy = dsInfoProxy; _dsTaskProxy = dsTaskProxy; @@ -99,7 +100,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 +131,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)) } @@ -145,7 +147,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { _logger.Debug(e, "Failed to get config from Download Station"); - throw e; + throw; } } @@ -233,12 +235,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 +261,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 +274,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 +293,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) @@ -318,7 +315,9 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { switch (task.Status) { + case DownloadStationTaskStatus.Unknown: case DownloadStationTaskStatus.Waiting: + case DownloadStationTaskStatus.FilehostingWaiting: return task.Size == 0 || GetRemainingSize(task) > 0 ? DownloadItemStatus.Queued : DownloadItemStatus.Completed; case DownloadStationTaskStatus.Paused: return DownloadItemStatus.Paused; diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs index 5727dea8b..1c2d660da 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(); @@ -72,7 +62,9 @@ namespace NzbDrone.Core.Download.Clients.Hadouken RemainingSize = torrent.TotalSize - torrent.DownloadedBytes, RemainingTime = eta, Title = torrent.Name, - TotalSize = torrent.TotalSize + TotalSize = torrent.TotalSize, + SeedRatio = torrent.DownloadedBytes <= 0 ? 0 : + (double) torrent.UploadedBytes / torrent.DownloadedBytes }; if (!string.IsNullOrEmpty(torrent.Error)) @@ -97,14 +89,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 +109,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 +155,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..e9eb8e651 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) @@ -124,6 +138,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken TotalSize = Convert.ToInt64(item[3]), Progress = Convert.ToDouble(item[4]), DownloadedBytes = Convert.ToInt64(item[5]), + UploadedBytes = Convert.ToInt64(item[6]), DownloadRate = Convert.ToInt64(item[9]), Label = Convert.ToString(item[11]), Error = Convert.ToString(item[21]), diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs index a52180ca2..b84c2b3f5 100644 --- a/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs @@ -13,6 +13,7 @@ public bool IsSeeding { get; set; } public long TotalSize { get; set; } public long DownloadedBytes { get; set; } + public long UploadedBytes { get; set; } public long DownloadRate { get; set; } public string Error { get; set; } } diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs index dc3595615..0d38a29a0 100644 --- a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -23,8 +23,9 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex IConfigService configService, IDiskProvider diskProvider, IRemotePathMappingService remotePathMappingService, + IValidateNzbs nzbValidationService, Logger logger) - : base(httpClient, configService, diskProvider, remotePathMappingService, logger) + : base(httpClient, configService, diskProvider, remotePathMappingService, nzbValidationService, logger) { _proxy = proxy; } @@ -47,17 +48,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 +63,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 +125,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex { _proxy.Remove(queueItem.Id, deleteData, Settings); } - } + } } protected List GetGroups() @@ -140,9 +133,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 +159,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 +180,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 +249,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..ab4b29783 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; +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 { @@ -25,8 +26,9 @@ namespace NzbDrone.Core.Download.Clients.Nzbget IConfigService configService, IDiskProvider diskProvider, IRemotePathMappingService remotePathMappingService, + IValidateNzbs nzbValidationService, Logger logger) - : base(httpClient, configService, diskProvider, remotePathMappingService, logger) + : base(httpClient, configService, diskProvider, remotePathMappingService, nzbValidationService, logger) { _proxy = proxy; } @@ -51,19 +53,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 +74,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 +110,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 +128,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 +195,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 +270,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 +298,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 +308,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/Nzbget/NzbgetSettings.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs index 667312174..5bd604b36 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -52,12 +52,12 @@ namespace NzbDrone.Core.Download.Clients.Nzbget [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] public int OlderTvPriority { get; set; } - [FieldDefinition(7, Label = "Use SSL", Type = FieldType.Checkbox)] - public bool UseSsl { get; set; } - - [FieldDefinition(8, Label = "Add Paused", Type = FieldType.Checkbox, HelpText = "This option requires at least NzbGet version 16.0")] + [FieldDefinition(7, Label = "Add Paused", Type = FieldType.Checkbox, HelpText = "This option requires at least NzbGet version 16.0")] public bool AddPaused { get; set; } + [FieldDefinition(8, Label = "Use SSL", Type = FieldType.Checkbox)] + public bool UseSsl { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); 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..2e58c9cd9 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using NzbDrone.Common.Disk; @@ -48,6 +48,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent _proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); } + SetInitialState(hash.ToLower()); + return hash; } @@ -55,18 +57,34 @@ 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); + } + + SetInitialState(hash.ToLower()); return hash; } @@ -75,19 +93,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(); @@ -100,13 +107,14 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent item.TotalSize = torrent.Size; item.DownloadClient = Definition.Name; item.RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)); - item.RemainingTime = TimeSpan.FromSeconds(torrent.Eta); + item.RemainingTime = GetRemainingTime(torrent); + item.SeedRatio = torrent.Ratio; item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)); // 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) { @@ -117,7 +125,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { case "error": // some error occurred, applies to paused torrents item.Status = DownloadItemStatus.Failed; - item.Message = "QBittorrent is reporting an error"; + item.Message = "qBittorrent is reporting an error"; break; case "pausedDL": // torrent is paused and has NOT finished downloading @@ -129,7 +137,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 +168,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 +185,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { failures.AddIfNotNull(TestConnection()); if (failures.Any()) return; + failures.AddIfNotNull(TestPrioritySupport()); failures.AddIfNotNull(TestGetTorrents()); } @@ -218,7 +227,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent var config = _proxy.GetConfig(Settings); if (config.MaxRatioEnabled && config.RemoveOnMaxRatio) { - return new NzbDroneValidationFailure(String.Empty, "QBittorrent is configured to remove torrents when they reach their Share Ratio Limit") + return new NzbDroneValidationFailure(String.Empty, "qBittorrent is configured to remove torrents when they reach their Share Ratio Limit") { DetailedDescription = "Sonarr will be unable to perform Completed Download Handling as configured. You can fix this in qBittorrent ('Tools -> Options...' in the menu) by changing 'Options -> BitTorrent -> Share Ratio Limiting' from 'Remove them' to 'Pause them'." }; @@ -226,7 +235,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 +243,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 +255,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,11 +305,44 @@ 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); } return null; } + + private void SetInitialState(string hash) + { + try + { + switch ((QBittorrentState)Settings.InitialState) + { + case QBittorrentState.ForceStart: + _proxy.SetForceStart(hash, true, Settings); + break; + case QBittorrentState.Start: + _proxy.ResumeTorrent(hash, Settings); + break; + case QBittorrentState.Pause: + _proxy.PauseTorrent(hash, Settings); + break; + } + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to set inital state for {0}.", hash); + } + } + + protected TimeSpan? GetRemainingTime(QBittorrentTorrent torrent) + { + if (torrent.Eta < 0 || torrent.Eta > 365 * 24 * 3600) + { + return null; + } + + return TimeSpan.FromSeconds((int)torrent.Eta); + } } } 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..600802a73 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Net; using NLog; @@ -23,6 +23,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings); void SetTorrentLabel(string hash, string label, QBittorrentSettings settings); void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings); + void PauseTorrent(string hash, QBittorrentSettings settings); + void ResumeTorrent(string hash, QBittorrentSettings settings); + void SetForceStart(string hash, bool enabled, QBittorrentSettings settings); } public class QBittorrentProxy : IQBittorrentProxy @@ -72,7 +75,23 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent .Post() .AddFormParameter("urls", torrentUrl); - ProcessRequest(request, settings); + if (settings.TvCategory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.TvCategory); + } + + if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + { + request.AddFormParameter("paused", true); + } + + 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 +100,23 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent .Post() .AddFormUpload("torrents", fileName, fileContent); - ProcessRequest(request, settings); + if (settings.TvCategory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.TvCategory); + } + + if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + { + request.AddFormParameter("paused", true); + } + + 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 +125,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,18 +136,19 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent .AddFormParameter("category", label); try { - ProcessRequest(setCategoryRequest, settings); + ProcessRequest(setCategoryRequest, settings); } catch(DownloadClientException ex) { - // if setCategory fails due to method not being found, then try older setLabel command for qbittorent < v.3.3.5 + // if setCategory fails due to method not being found, then try older setLabel command for qBittorrent < v.3.3.5 if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.NotFound) { var setLabelRequest = BuildRequest(settings).Resource("/command/setLabel") .Post() .AddFormParameter("hashes", hash) .AddFormParameter("label", label); - ProcessRequest(setLabelRequest, settings); + + ProcessRequest(setLabelRequest, settings); } } } @@ -125,12 +161,12 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent try { - var response = ProcessRequest(request, settings); + ProcessRequest(request, settings); } catch (DownloadClientException ex) { // qBittorrent rejects all Prio commands with 403: Forbidden if Options -> BitTorrent -> Torrent Queueing is not enabled -#warning FIXME: so wouldn't the reauthenticate logic trigger on Forbidden? + #warning FIXME: so wouldn't the reauthenticate logic trigger on Forbidden? if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Forbidden) { return; @@ -141,6 +177,34 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } + public void PauseTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/pause") + .Post() + .AddFormParameter("hash", hash); + + ProcessRequest(request, settings); + } + + public void ResumeTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/resume") + .Post() + .AddFormParameter("hash", hash); + + ProcessRequest(request, settings); + } + + public void SetForceStart(string hash, bool enabled, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/setForceStart") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("value", enabled ? "true": "false"); + + ProcessRequest(request, settings); + } + private HttpRequestBuilder BuildRequest(QBittorrentSettings settings) { var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port); @@ -152,10 +216,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 @@ -176,15 +248,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } else { - throw new DownloadClientException("Failed to connect to qBitTorrent, check your settings.", ex); + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); } } catch (WebException ex) { - throw new DownloadClientException("Failed to connect to qBitTorrent, please check your settings.", ex); + 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) @@ -218,23 +290,23 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent _logger.Debug("qbitTorrent authentication failed."); if (ex.Response.StatusCode == HttpStatusCode.Forbidden) { - throw new DownloadClientAuthenticationException("Failed to authenticate with qbitTorrent.", ex); + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent.", ex); } - throw new DownloadClientException("Failed to connect to qBitTorrent, please check your settings.", ex); + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); } 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 { _logger.Debug("qbitTorrent authentication failed."); - throw new DownloadClientAuthenticationException("Failed to authenticate with qbitTorrent."); + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent."); } - _logger.Debug("qbitTorrent authentication succeeded."); + _logger.Debug("qBittorrent authentication succeeded."); cookies = response.GetCookies(); diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs index 9bb87ce63..337c065a9 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -21,7 +21,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public QBittorrentSettings() { Host = "localhost"; - Port = 9091; + Port = 8080; TvCategory = "tv-sonarr"; } @@ -46,7 +46,10 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] public int OlderTvPriority { get; set; } - [FieldDefinition(7, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use a secure connection. See Options -> Web UI -> 'Use HTTPS instead of HTTP' in qBittorrent.")] + [FieldDefinition(7, Label = "Initial State", Type = FieldType.Select, SelectOptions = typeof(QBittorrentState), HelpText = "Initial state for torrents added to qBittorrent")] + public int InitialState { get; set; } + + [FieldDefinition(8, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use a secure connection. See Options -> Web UI -> 'Use HTTPS instead of HTTP' in qBittorrent.")] public bool UseSsl { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentState.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentState.cs new file mode 100644 index 000000000..56c5ddf1a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentState.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public enum QBittorrentState + { + Start = 0, + ForceStart = 1, + Pause = 2 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs index 266d22f95..d5fa0b5e7 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using System.Numerics; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.QBittorrent { @@ -13,7 +14,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public double Progress { get; set; } // Torrent progress (%/100) - public ulong Eta { get; set; } // Torrent ETA (seconds) + public BigInteger Eta { get; set; } // Torrent ETA (seconds) (QBit contains a bug exceeding ulong limits) public string State { get; set; } // Torrent state. See possible values here below diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs index 70ae19373..1e4840df8 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -23,8 +23,9 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd IConfigService configService, IDiskProvider diskProvider, IRemotePathMappingService remotePathMappingService, + IValidateNzbs nzbValidationService, Logger logger) - : base(httpClient, configService, diskProvider, remotePathMappingService, logger) + : base(httpClient, configService, diskProvider, remotePathMappingService, nzbValidationService, logger) { _proxy = proxy; } @@ -57,7 +58,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,8 +79,11 @@ 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) + if ((sabQueue.Paused && sabQueueItem.Priority != SabnzbdPriority.Force) || + sabQueueItem.Status == SabnzbdDownloadStatus.Paused) { queueItem.Status = DownloadItemStatus.Paused; @@ -110,17 +114,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 +136,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 +180,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd } } + historyItems.Add(historyItem); } @@ -244,7 +242,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 +254,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 +317,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 +359,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 +385,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..dd4d8eea4 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs @@ -1,7 +1,6 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; -using System.Net; using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; @@ -33,17 +32,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(); @@ -76,6 +65,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission item.OutputPath = GetOutputPath(outputPath, torrent); item.TotalSize = torrent.TotalSize; item.RemainingSize = torrent.LeftUntilDone; + item.SeedRatio = torrent.DownloadedEver <= 0 ? 0 : + (double) torrent.UploadedEver / torrent.DownloadedEver; + if (torrent.Eta >= 0) { item.RemainingTime = TimeSpan.FromSeconds(torrent.Eta); @@ -86,8 +78,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 +98,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission item.Status = DownloadItemStatus.Downloading; } - item.IsReadOnly = torrent.Status != TransmissionTorrentStatus.Stopped; + item.CanMoveFiles = item.CanBeRemoved = + torrent.Status == TransmissionTorrentStatus.Stopped && + item.SeedRatio >= torrent.SeedRatioLimit; items.Add(item); } @@ -118,17 +113,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)) } @@ -138,6 +133,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) { _proxy.AddTorrentFromUrl(magnetLink, GetDownloadDirectory(), Settings); + _proxy.SetTorrentSeedingConfiguration(hash, remoteEpisode.SeedConfiguration, Settings); var isRecentEpisode = remoteEpisode.IsRecentEpisode(); @@ -153,6 +149,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) { _proxy.AddTorrentFromData(fileContent, GetDownloadDirectory(), Settings); + _proxy.SetTorrentSeedingConfiguration(hash, remoteEpisode.SeedConfiguration, Settings); var isRecentEpisode = remoteEpisode.IsRecentEpisode(); @@ -183,17 +180,13 @@ namespace NzbDrone.Core.Download.Clients.Transmission { return Settings.TvDirectory; } - else if (Settings.TvCategory.IsNotNullOrWhiteSpace()) - { - var config = _proxy.GetConfig(Settings); - var destDir = (string)config.GetValueOrDefault("download-dir"); - return string.Format("{0}/{1}", destDir.TrimEnd('/'), Settings.TvCategory); - } - else - { - return null; - } + if (!Settings.TvCategory.IsNotNullOrWhiteSpace()) return null; + + var config = _proxy.GetConfig(Settings); + var destDir = (string)config.GetValueOrDefault("download-dir"); + + return $"{destDir.TrimEnd('/')}/{Settings.TvCategory}"; } protected ValidationFailure TestConnection() @@ -204,27 +197,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 +229,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..567001e06 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using System.Collections.Generic; using NzbDrone.Common.Extensions; @@ -37,7 +37,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission _authSessionIDCache = cacheManager.GetCache(GetType(), "authSessionID"); } - + public List GetTorrents(TransmissionSettings settings) { var result = GetTorrentStatus(settings); @@ -51,6 +51,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission { var arguments = new Dictionary(); arguments.Add("filename", torrentUrl); + arguments.Add("paused", settings.AddPaused); if (!downloadDirectory.IsNullOrWhiteSpace()) { @@ -64,6 +65,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission { var arguments = new Dictionary(); arguments.Add("metainfo", Convert.ToBase64String(torrentData)); + arguments.Add("paused", settings.AddPaused); if (!downloadDirectory.IsNullOrWhiteSpace()) { @@ -75,8 +77,10 @@ namespace NzbDrone.Core.Download.Clients.Transmission public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, TransmissionSettings settings) { + if (seedConfiguration == null) return; + var arguments = new Dictionary(); - arguments.Add("ids", new string[] { hash }); + arguments.Add("ids", new[] { hash }); if (seedConfiguration.Ratio != null) { @@ -165,9 +169,12 @@ namespace NzbDrone.Core.Download.Clients.Transmission "leftUntilDone", "isFinished", "eta", - "errorString" + "errorString", + "uploadedEver", + "downloadedEver", + "seedRatioLimit" }; - + var arguments = new Dictionary(); arguments.Add("fields", fields); @@ -235,57 +242,69 @@ namespace NzbDrone.Core.Download.Clients.Transmission requestBuilder.SetHeader("X-Transmission-Session-Id", sessionId); } - + 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/Transmission/TransmissionSettings.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs index d551c05d3..ac7d47e45 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; @@ -62,7 +62,10 @@ namespace NzbDrone.Core.Download.Clients.Transmission [FieldDefinition(8, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] public int OlderTvPriority { get; set; } - [FieldDefinition(9, Label = "Use SSL", Type = FieldType.Checkbox)] + [FieldDefinition(9, Label = "Add Paused", Type = FieldType.Checkbox)] + public bool AddPaused { get; set; } + + [FieldDefinition(10, Label = "Use SSL", Type = FieldType.Checkbox)] public bool UseSsl { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs index 3845ce0b0..c3369e337 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs @@ -23,5 +23,11 @@ public int SecondsDownloading { get; set; } public string ErrorString { get; set; } + + public long DownloadedEver { get; set; } + + public long UploadedEver { get; set; } + + public long SeedRatioLimit { get; set; } } } 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..535bcd588 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -81,57 +81,61 @@ 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; + item.SeedRatio = torrent.Ratio; + + 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 +148,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 +181,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 +196,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..40da8f483 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs @@ -40,6 +40,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent { _proxy.AddTorrentFromUrl(magnetLink, Settings); _proxy.SetTorrentLabel(hash, Settings.TvCategory, Settings); + _proxy.SetTorrentSeedingConfiguration(hash, remoteEpisode.SeedConfiguration, Settings); var isRecentEpisode = remoteEpisode.IsRecentEpisode(); @@ -49,6 +50,8 @@ namespace NzbDrone.Core.Download.Clients.UTorrent _proxy.MoveTorrentToTopInQueue(hash, Settings); } + _proxy.SetState(hash, (UTorrentState)Settings.IntialState, Settings); + return hash; } @@ -56,6 +59,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent { _proxy.AddTorrentFromFile(filename, fileContent, Settings); _proxy.SetTorrentLabel(hash, Settings.TvCategory, Settings); + _proxy.SetTorrentSeedingConfiguration(hash, remoteEpisode.SeedConfiguration, Settings); var isRecentEpisode = remoteEpisode.IsRecentEpisode(); @@ -65,6 +69,8 @@ namespace NzbDrone.Core.Download.Clients.UTorrent _proxy.MoveTorrentToTopInQueue(hash, Settings); } + _proxy.SetState(hash, (UTorrentState)Settings.IntialState, Settings); + return hash; } @@ -72,42 +78,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(); @@ -125,6 +96,8 @@ namespace NzbDrone.Core.Download.Clients.UTorrent item.Category = torrent.Label; item.DownloadClient = Definition.Name; item.RemainingSize = torrent.Remaining; + item.SeedRatio = torrent.Ratio; + if (torrent.Eta != -1) { item.RemainingTime = TimeSpan.FromSeconds(torrent.Eta); @@ -132,7 +105,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent var outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.RootDownloadPath)); - if (outputPath == null || outputPath.FileName == torrent.Name) + if (outputPath.FileName == torrent.Name) { item.OutputPath = outputPath; } @@ -165,7 +138,9 @@ 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 +148,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 +208,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 +241,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 +249,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 +261,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 +276,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..557ae0e6e 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs @@ -22,6 +22,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent void RemoveTorrent(string hash, bool removeData, UTorrentSettings settings); void SetTorrentLabel(string hash, string label, UTorrentSettings settings); void MoveTorrentToTopInQueue(string hash, UTorrentSettings settings); + void SetState(string hash, UTorrentState state, UTorrentSettings settings); } public class UTorrentProxy : IUTorrentProxy @@ -68,14 +69,14 @@ namespace NzbDrone.Core.Download.Clients.UTorrent return configuration; } - public UTorrentResponse GetTorrents(string cacheID, UTorrentSettings settings) + public UTorrentResponse GetTorrents(string cacheId, UTorrentSettings settings) { var requestBuilder = BuildRequest(settings) .AddQueryParam("list", 1); - if (cacheID.IsNotNullOrWhiteSpace()) + if (cacheId.IsNotNullOrWhiteSpace()) { - requestBuilder.AddQueryParam("cid", cacheID); + requestBuilder.AddQueryParam("cid", cacheId); } var result = ProcessRequest(requestBuilder, settings); @@ -98,17 +99,19 @@ namespace NzbDrone.Core.Download.Clients.UTorrent .Post() .AddQueryParam("action", "add-file") .AddQueryParam("path", string.Empty) - .AddFormUpload("torrent_file", fileName, fileContent, @"application/octet-stream"); + .AddFormUpload("torrent_file", fileName, fileContent); ProcessRequest(requestBuilder, settings); } public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, UTorrentSettings settings) { + if (seedConfiguration == null) return; + var requestBuilder = BuildRequest(settings) .AddQueryParam("action", "setprops") .AddQueryParam("hash", hash); - + requestBuilder.AddQueryParam("s", "seed_override") .AddQueryParam("v", 1); @@ -157,6 +160,15 @@ namespace NzbDrone.Core.Download.Clients.UTorrent ProcessRequest(requestBuilder, settings); } + public void SetState(string hash, UTorrentState state, UTorrentSettings settings) + { + var requestBuilder = BuildRequest(settings) + .AddQueryParam("action", state.ToString().ToLowerInvariant()) + .AddQueryParam("hash", hash); + + ProcessRequest(requestBuilder, settings); + } + private HttpRequestBuilder BuildRequest(UTorrentSettings settings) { var requestBuilder = new HttpRequestBuilder(false, settings.Host, settings.Port) @@ -244,7 +256,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/Clients/uTorrent/UTorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs index 52185b134..f76aa6457 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -22,7 +22,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent public UTorrentSettings() { Host = "localhost"; - Port = 9091; + Port = 8080; TvCategory = "tv-sonarr"; } @@ -47,6 +47,9 @@ namespace NzbDrone.Core.Download.Clients.UTorrent [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] public int OlderTvPriority { get; set; } + [FieldDefinition(7, Label = "Initial State", Type = FieldType.Select, SelectOptions = typeof(UTorrentState), HelpText = "Initial state for torrents added to uTorrent")] + public int IntialState { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UtorrentState.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UtorrentState.cs new file mode 100644 index 000000000..17feaa485 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UtorrentState.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public enum UTorrentState + { + Start = 0, + ForceStart = 1, + Pause = 2, + Stop = 3 + } +} 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..3b5922c6a 100644 --- a/src/NzbDrone.Core/Download/DownloadClientItem.cs +++ b/src/NzbDrone.Core/Download/DownloadClientItem.cs @@ -15,13 +15,16 @@ namespace NzbDrone.Core.Download public long TotalSize { get; set; } public long RemainingSize { get; set; } public TimeSpan? RemainingTime { get; set; } + public double? SeedRatio { get; set; } public OsPath OutputPath { get; set; } public string Message { get; set; } 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..179a72fab 100644 --- a/src/NzbDrone.Core/Download/DownloadService.cs +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -1,10 +1,12 @@ -using System; +using System; using NLog; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.TPL; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Exceptions; using NzbDrone.Core.Indexers; using NzbDrone.Core.Messaging.Events; @@ -21,21 +23,27 @@ 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 ISeedConfigProvider _seedConfigProvider; private readonly Logger _logger; public DownloadService(IProvideDownloadClient downloadClientProvider, - IIndexerStatusService indexerStatusService, - IRateLimitService rateLimitService, - IEventAggregator eventAggregator, - Logger logger) + IDownloadClientStatusService downloadClientStatusService, + IIndexerStatusService indexerStatusService, + IRateLimitService rateLimitService, + IEventAggregator eventAggregator, + ISeedConfigProvider seedConfigProvider, + Logger logger) { _downloadClientProvider = downloadClientProvider; + _downloadClientStatusService = downloadClientStatusService; _indexerStatusService = indexerStatusService; _rateLimitService = rateLimitService; _eventAggregator = eventAggregator; + _seedConfigProvider = seedConfigProvider; _logger = logger; } @@ -49,10 +57,12 @@ namespace NzbDrone.Core.Download if (downloadClient == null) { - _logger.Warn("{0} Download client isn't configured yet.", remoteEpisode.Release.DownloadProtocol); - return; + throw new DownloadClientUnavailableException($"{remoteEpisode.Release.DownloadProtocol} Download client isn't configured yet"); } + // Get the seed configuration for this release. + remoteEpisode.SeedConfiguration = _seedConfigProvider.GetSeedConfiguration(remoteEpisode); + // Limit grabs to 2 per second. if (remoteEpisode.Release.DownloadUrl.IsNotNullOrWhiteSpace() && !remoteEpisode.Release.DownloadUrl.StartsWith("magnet:")) { @@ -64,8 +74,14 @@ namespace NzbDrone.Core.Download try { downloadClientId = downloadClient.Download(remoteEpisode); + _downloadClientStatusService.RecordSuccess(downloadClient.Definition.Id); _indexerStatusService.RecordSuccess(remoteEpisode.Release.IndexerId); } + catch (ReleaseUnavailableException) + { + _logger.Trace("Release {0} no longer available on indexer.", remoteEpisode); + throw; + } catch (ReleaseDownloadException ex) { var http429 = ex.InnerException as TooManyRequestsException; @@ -92,4 +108,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/InvalidNzbException.cs b/src/NzbDrone.Core/Download/InvalidNzbException.cs new file mode 100644 index 000000000..5607590d9 --- /dev/null +++ b/src/NzbDrone.Core/Download/InvalidNzbException.cs @@ -0,0 +1,24 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Download +{ + public class InvalidNzbException : NzbDroneException + { + public InvalidNzbException(string message, params object[] args) : base(message, args) + { + } + + public InvalidNzbException(string message) : base(message) + { + } + + public InvalidNzbException(string message, Exception innerException, params object[] args) : base(message, innerException, args) + { + } + + public InvalidNzbException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/NzbValidationService.cs b/src/NzbDrone.Core/Download/NzbValidationService.cs new file mode 100644 index 000000000..5385a06a8 --- /dev/null +++ b/src/NzbDrone.Core/Download/NzbValidationService.cs @@ -0,0 +1,45 @@ +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Download +{ + public interface IValidateNzbs + { + void Validate(string filename, byte[] fileContent); + } + + public class NzbValidationService : IValidateNzbs + { + public void Validate(string filename, byte[] fileContent) + { + var reader = new StreamReader(new MemoryStream(fileContent)); + + using (var xmlTextReader = XmlReader.Create(reader, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, IgnoreComments = true })) + { + var xDoc = XDocument.Load(xmlTextReader); + var nzb = xDoc.Root; + + if (nzb == null) + { + throw new InvalidNzbException("Invalid NZB: No Root element [{0}]", filename); + } + + if (!nzb.Name.LocalName.Equals("nzb")) + { + throw new InvalidNzbException("Invalid NZB: Unexpected root element. Expected 'nzb' found '{0}' [{1}]", nzb.Name.LocalName, filename); + } + + var ns = nzb.Name.Namespace; + var files = nzb.Elements(ns + "file").ToList(); + + if (files.Empty()) + { + throw new InvalidNzbException("Invalid NZB: No files [{0}]", filename); + } + } + } + } +} 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/PendingReleaseRepository.cs b/src/NzbDrone.Core/Download/Pending/PendingReleaseRepository.cs index b98334978..8078bd508 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingReleaseRepository.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingReleaseRepository.cs @@ -8,6 +8,7 @@ namespace NzbDrone.Core.Download.Pending { void DeleteBySeriesId(int seriesId); List AllBySeriesId(int seriesId); + List WithoutFallback(); } public class PendingReleaseRepository : BasicRepository, IPendingReleaseRepository @@ -26,5 +27,10 @@ namespace NzbDrone.Core.Download.Pending { return Query.Where(p => p.SeriesId == seriesId); } + + public List WithoutFallback() + { + return Query.Where(p => p.Reason != PendingReleaseReason.Fallback); + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs index 8585a1704..262e4102b 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using Marr.Data; using NLog; using NzbDrone.Common.Crypto; using NzbDrone.Common.Extensions; @@ -20,14 +21,14 @@ namespace NzbDrone.Core.Download.Pending { public interface IPendingReleaseService { - void Add(DownloadDecision decision); - + void Add(DownloadDecision decision, PendingReleaseReason reason); + void AddMany(List> decisions); List GetPending(); List GetPendingRemoteEpisodes(int seriesId); List GetPendingQueue(); Queue.Queue FindPendingQueueItem(int queueId); void RemovePendingQueueItems(int queueId); - RemoteEpisode OldestPendingRelease(int seriesId, IEnumerable episodeIds); + RemoteEpisode OldestPendingRelease(int seriesId, int[] episodeIds); } public class PendingReleaseService : IPendingReleaseService, @@ -66,25 +67,74 @@ namespace NzbDrone.Core.Download.Pending _logger = logger; } - - public void Add(DownloadDecision decision) + public void Add(DownloadDecision decision, PendingReleaseReason reason) { - var alreadyPending = GetPendingReleases(); + AddMany(new List> { Tuple.Create(decision, reason) }); + } - var episodeIds = decision.RemoteEpisode.Episodes.Select(e => e.Id); - - var existingReports = alreadyPending.Where(r => r.RemoteEpisode.Episodes.Select(e => e.Id) - .Intersect(episodeIds) - .Any()); - - if (existingReports.Any(MatchingReleasePredicate(decision.RemoteEpisode.Release))) + public void AddMany(List> decisions) + { + foreach (var seriesDecisions in decisions.GroupBy(v => v.Item1.RemoteEpisode.Series.Id)) { - _logger.Debug("This release is already pending, not adding again"); - return; - } + var series = seriesDecisions.First().Item1.RemoteEpisode.Series; + var alreadyPending = _repository.AllBySeriesId(series.Id); - _logger.Debug("Adding release to pending releases"); - Insert(decision); + alreadyPending = IncludeRemoteEpisodes(alreadyPending, seriesDecisions.ToDictionaryIgnoreDuplicates(v => v.Item1.RemoteEpisode.Release.Title, v => v.Item1.RemoteEpisode)); + var alreadyPendingByEpisode = CreateEpisodeLookup(alreadyPending); + + foreach (var pair in seriesDecisions) + { + var decision = pair.Item1; + var reason = pair.Item2; + + var episodeIds = decision.RemoteEpisode.Episodes.Select(e => e.Id); + + var existingReports = episodeIds.SelectMany(v => alreadyPendingByEpisode[v] ?? Enumerable.Empty()) + .Distinct().ToList(); + + var matchingReports = existingReports.Where(MatchingReleasePredicate(decision.RemoteEpisode.Release)).ToList(); + + if (matchingReports.Any()) + { + var matchingReport = matchingReports.First(); + + 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); + } + else + { + _logger.Debug("The release {0} is already pending with reason {1}, not adding again", decision.RemoteEpisode, reason); + } + + if (matchingReports.Count() > 1) + { + _logger.Debug("The release {0} had {1} duplicate pending, removing duplicates.", decision.RemoteEpisode, matchingReports.Count() - 1); + + foreach (var duplicate in matchingReports.Skip(1)) + { + _repository.Delete(duplicate.Id); + alreadyPending.Remove(duplicate); + alreadyPendingByEpisode = CreateEpisodeLookup(alreadyPending); + } + } + + continue; + } + + _logger.Debug("Adding release {0} to pending releases with reason {1}", decision.RemoteEpisode, reason); + Insert(decision, reason); + } + } + } + + private ILookup CreateEpisodeLookup(IEnumerable alreadyPending) + { + return alreadyPending.SelectMany(v => v.RemoteEpisode.Episodes + .Select(d => new { Episode = d, PendingRelease = v })) + .ToLookup(v => v.Episode.Id, v => v.PendingRelease); } public List GetPending() @@ -101,14 +151,14 @@ 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(); } public List GetPendingRemoteEpisodes(int seriesId) { - return _repository.AllBySeriesId(seriesId).Select(GetRemoteEpisode).ToList(); + return IncludeRemoteEpisodes(_repository.AllBySeriesId(seriesId)).Select(v => v.RemoteEpisode).ToList(); } public List GetPendingQueue() @@ -117,7 +167,8 @@ namespace NzbDrone.Core.Download.Pending var nextRssSync = new Lazy(() => _taskManager.GetNextExecution(typeof(RssSyncCommand))); - foreach (var pendingRelease in GetPendingReleases()) + var pendingReleases = IncludeRemoteEpisodes(_repository.WithoutFallback()); + foreach (var pendingRelease in pendingReleases) { foreach (var episode in pendingRelease.RemoteEpisode.Episodes) { @@ -132,6 +183,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 +200,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); } } @@ -181,24 +240,81 @@ namespace NzbDrone.Core.Download.Pending _repository.DeleteMany(releasesToRemove.Select(c => c.Id)); } - public RemoteEpisode OldestPendingRelease(int seriesId, IEnumerable episodeIds) + public RemoteEpisode OldestPendingRelease(int seriesId, int[] episodeIds) { - return GetPendingRemoteEpisodes(seriesId).Where(r => r.Episodes.Select(e => e.Id).Intersect(episodeIds).Any()) - .OrderByDescending(p => p.Release.AgeHours) - .FirstOrDefault(); + var seriesReleases = GetPendingReleases(seriesId); + + return seriesReleases.Select(r => r.RemoteEpisode) + .Where(r => r.Episodes.Select(e => e.Id).Intersect(episodeIds).Any()) + .OrderByDescending(p => p.Release.AgeHours) + .FirstOrDefault(); } private List GetPendingReleases() + { + return IncludeRemoteEpisodes(_repository.All().ToList()); + } + + private List GetPendingReleases(int seriesId) + { + return IncludeRemoteEpisodes(_repository.AllBySeriesId(seriesId).ToList()); + } + + private List IncludeRemoteEpisodes(List releases, Dictionary knownRemoteEpisodes = null) { var result = new List(); - foreach (var release in _repository.All()) + var seriesMap = new Dictionary(); + + if (knownRemoteEpisodes != null) { - var remoteEpisode = GetRemoteEpisode(release); + foreach (var series in knownRemoteEpisodes.Values.Select(v => v.Series)) + { + if (!seriesMap.ContainsKey(series.Id)) + { + seriesMap[series.Id] = series; + } + } + } - if (remoteEpisode == null) continue; + foreach (var series in _seriesService.GetSeries(releases.Select(v => v.SeriesId).Distinct().Where(v => !seriesMap.ContainsKey(v)))) + { + seriesMap[series.Id] = series; + } - release.RemoteEpisode = remoteEpisode; + foreach (var release in releases) + { + var series = seriesMap.GetValueOrDefault(release.SeriesId); + + // Just in case the series was removed, but wasn't cleaned up yet (housekeeper will clean it up) + if (series == null) return null; + + List episodes; + + RemoteEpisode knownRemoteEpisode; + if (knownRemoteEpisodes != null && knownRemoteEpisodes.TryGetValue(release.Release.Title, out knownRemoteEpisode)) + { + episodes = knownRemoteEpisode.Episodes; + } + else + { + if (ValidateParsedEpisodeInfo.ValidateForSeriesType(release.ParsedEpisodeInfo, series)) + { + episodes = _parsingService.GetEpisodes(release.ParsedEpisodeInfo, series, true); + } + else + { + episodes = new List(); + } + } + + release.RemoteEpisode = new RemoteEpisode + { + Series = series, + Episodes = episodes, + ParsedEpisodeInfo = release.ParsedEpisodeInfo, + Release = release.Release + }; result.Add(release); } @@ -206,25 +322,7 @@ namespace NzbDrone.Core.Download.Pending return result; } - private RemoteEpisode GetRemoteEpisode(PendingRelease release) - { - var series = _seriesService.GetSeries(release.SeriesId); - - //Just in case the series was removed, but wasn't cleaned up yet (housekeeper will clean it up) - if (series == null) return null; - - var episodes = _parsingService.GetEpisodes(release.ParsedEpisodeInfo, series, true); - - return new RemoteEpisode - { - Series = series, - Episodes = episodes, - ParsedEpisodeInfo = release.ParsedEpisodeInfo, - Release = release.Release - }; - } - - private void Insert(DownloadDecision decision) + private void Insert(DownloadDecision decision, PendingReleaseReason reason) { _repository.Insert(new PendingRelease { @@ -232,7 +330,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()); @@ -262,7 +361,7 @@ namespace NzbDrone.Core.Download.Pending private void RemoveGrabbed(RemoteEpisode remoteEpisode) { - var pendingReleases = GetPendingReleases(); + var pendingReleases = GetPendingReleases(remoteEpisode.Series.Id); var episodeIds = remoteEpisode.Episodes.Select(e => e.Id); var existingReports = pendingReleases.Where(r => r.RemoteEpisode.Episodes.Select(e => e.Id) diff --git a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs index 05719587d..eebb59d2b 100644 --- a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs +++ b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs @@ -1,9 +1,13 @@ 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.Exceptions; +using NzbDrone.Core.Indexers; namespace NzbDrone.Core.Download { @@ -36,36 +40,34 @@ namespace NzbDrone.Core.Download var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(qualifiedReports); var grabbed = new List(); var pending = new List(); + var rejected = decisions.Where(d => d.Rejected).ToList(); + + var pendingAddQueue = 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); - pending.Add(report); + PreparePending(pendingAddQueue, grabbed, pending, report, PendingReleaseReason.Delay); 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) { + PreparePending(pendingAddQueue, grabbed, pending, report, PendingReleaseReason.DownloadClientUnavailable); continue; } @@ -74,15 +76,40 @@ namespace NzbDrone.Core.Download _downloadService.DownloadReport(remoteEpisode); grabbed.Add(report); } - catch (Exception e) + catch (ReleaseUnavailableException) { - //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); + _logger.Warn("Failed to download release from indexer, no longer available. " + remoteEpisode); + rejected.Add(report); + } + catch (Exception ex) + { + if (ex is DownloadClientUnavailableException || ex is DownloadClientAuthenticationException) + { + _logger.Debug(ex, "Failed to send release to download client, storing until later. " + remoteEpisode); + PreparePending(pendingAddQueue, grabbed, pending, report, PendingReleaseReason.DownloadClientUnavailable); + + 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); + } } } - return new ProcessedDecisions(grabbed, pending, decisions.Where(d => d.Rejected).ToList()); + if (pendingAddQueue.Any()) + { + _pendingReleaseService.AddMany(pendingAddQueue); + } + + return new ProcessedDecisions(grabbed, pending, rejected); } internal List GetQualifiedReports(IEnumerable decisions) @@ -90,5 +117,34 @@ 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 void PreparePending(List> queue, List grabbed, List pending, DownloadDecision report, PendingReleaseReason reason) + { + // 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. + // If a higher quality release failed to add to the download client, but a lower quality release + // was sent to another client we still list it normally so it apparent that it'll grab next time. + // Delayed is treated the same, but only the first is listed the subsequent items as stored as Fallback. + + if (IsEpisodeProcessed(grabbed, report) || + IsEpisodeProcessed(pending, report)) + { + reason = PendingReleaseReason.Fallback; + } + + queue.Add(Tuple.Create(report, reason)); + pending.Add(report); + } } } diff --git a/src/NzbDrone.Core/Download/TorrentClientBase.cs b/src/NzbDrone.Core/Download/TorrentClientBase.cs index b1fcd7e2e..07f0bd42c 100644 --- a/src/NzbDrone.Core/Download/TorrentClientBase.cs +++ b/src/NzbDrone.Core/Download/TorrentClientBase.cs @@ -33,7 +33,7 @@ namespace NzbDrone.Core.Download _httpClient = httpClient; _torrentFileInfoReader = torrentFileInfoReader; } - + public override DownloadProtocol Protocol => DownloadProtocol.Torrent; public virtual bool PreferTorrentFile => false; @@ -61,7 +61,7 @@ namespace NzbDrone.Core.Download { magnetUrl = torrentInfo.MagnetUrl; } - + if (PreferTorrentFile) { if (torrentUrl.IsNotNullOrWhiteSpace()) @@ -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"); @@ -158,6 +160,12 @@ namespace NzbDrone.Core.Download } catch (HttpException ex) { + if (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + _logger.Error(ex, "Downloading torrent file for episode '{0}' failed since it no longer exists ({1})", remoteEpisode.Release.Title, torrentUrl); + throw new ReleaseUnavailableException(remoteEpisode.Release, "Downloading torrent failed", ex); + } + if ((int)ex.Response.StatusCode == 429) { _logger.Error("API Grab Limit reached for {0}", torrentUrl); 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..4bc95903a 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; } @@ -76,7 +78,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads { // Try parsing the original source title and if that fails, try parsing it as a special // TODO: Pass the TVDB ID and TVRage IDs in as well so we have a better chance for finding the item - parsedEpisodeInfo = Parser.Parser.ParseTitle(firstHistoryItem.SourceTitle) ?? _parsingService.ParseSpecialEpisodeTitle(firstHistoryItem.SourceTitle, 0, 0); + parsedEpisodeInfo = Parser.Parser.ParseTitle(firstHistoryItem.SourceTitle) ?? _parsingService.ParseSpecialEpisodeTitle(parsedEpisodeInfo, firstHistoryItem.SourceTitle, 0, 0); if (parsedEpisodeInfo != null) { @@ -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/Download/UsenetClientBase.cs b/src/NzbDrone.Core/Download/UsenetClientBase.cs index a6c0ed7d5..c6be7ab45 100644 --- a/src/NzbDrone.Core/Download/UsenetClientBase.cs +++ b/src/NzbDrone.Core/Download/UsenetClientBase.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using NzbDrone.Common.Disk; using NzbDrone.Common.Http; using NzbDrone.Core.Exceptions; @@ -16,17 +16,20 @@ namespace NzbDrone.Core.Download where TSettings : IProviderConfig, new() { protected readonly IHttpClient _httpClient; + private readonly IValidateNzbs _nzbValidationService; protected UsenetClientBase(IHttpClient httpClient, IConfigService configService, IDiskProvider diskProvider, IRemotePathMappingService remotePathMappingService, + IValidateNzbs nzbValidationService, Logger logger) : base(configService, diskProvider, remotePathMappingService, logger) { _httpClient = httpClient; + _nzbValidationService = nzbValidationService; } - + public override DownloadProtocol Protocol => DownloadProtocol.Usenet; protected abstract string AddFromNzbFile(RemoteEpisode remoteEpisode, string filename, byte[] fileContent); @@ -46,6 +49,12 @@ namespace NzbDrone.Core.Download } catch (HttpException ex) { + if (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + _logger.Error(ex, "Downloading nzb file for episode '{0}' failed since it no longer exists ({1})", remoteEpisode.Release.Title, url); + throw new ReleaseUnavailableException(remoteEpisode.Release, "Downloading torrent failed", ex); + } + if ((int)ex.Response.StatusCode == 429) { _logger.Error("API Grab Limit reached for {0}", url); @@ -64,6 +73,8 @@ namespace NzbDrone.Core.Download throw new ReleaseDownloadException(remoteEpisode.Release, "Downloading nzb failed", ex); } + _nzbValidationService.Validate(filename, nzbData); + _logger.Info("Adding report [{0}] to the queue.", remoteEpisode.Release.Title); return AddFromNzbFile(remoteEpisode, filename, nzbData); } diff --git a/src/NzbDrone.Core/Exceptions/ReleaseUnavailableException.cs b/src/NzbDrone.Core/Exceptions/ReleaseUnavailableException.cs new file mode 100644 index 000000000..41442c1ea --- /dev/null +++ b/src/NzbDrone.Core/Exceptions/ReleaseUnavailableException.cs @@ -0,0 +1,28 @@ +using System; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Exceptions +{ + public class ReleaseUnavailableException : ReleaseDownloadException + { + public ReleaseUnavailableException(ReleaseInfo release, string message, params object[] args) + : base(release, message, args) + { + } + + public ReleaseUnavailableException(ReleaseInfo release, string message) + : base(release, message) + { + } + + public ReleaseUnavailableException(ReleaseInfo release, string message, Exception innerException, params object[] args) + : base(release, message, innerException, args) + { + } + + public ReleaseUnavailableException(ReleaseInfo release, string message, Exception innerException) + : base(release, message, innerException) + { + } + } +} 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..ce5b660b6 100644 --- a/src/NzbDrone.Core/Extras/ExtraService.cs +++ b/src/NzbDrone.Core/Extras/ExtraService.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Core.Extras { public interface IExtraService { - void ImportExtraFiles(LocalEpisode localEpisode, EpisodeFile episodeFile, bool isReadOnly); + void ImportEpisode(LocalEpisode localEpisode, EpisodeFile episodeFile, bool isReadOnly); } public class ExtraService : IExtraService, @@ -48,15 +48,15 @@ namespace NzbDrone.Core.Extras _logger = logger; } - public void ImportExtraFiles(LocalEpisode localEpisode, EpisodeFile episodeFile, bool isReadOnly) + public void ImportEpisode(LocalEpisode localEpisode, EpisodeFile episodeFile, bool isReadOnly) { - var series = localEpisode.Series; - - foreach (var extraFileManager in _extraFileManagers) - { - extraFileManager.CreateAfterEpisodeImport(series, episodeFile); - } + ImportExtraFiles(localEpisode, episodeFile, isReadOnly); + + CreateAfterImport(localEpisode.Series, episodeFile); + } + private void ImportExtraFiles(LocalEpisode localEpisode, EpisodeFile episodeFile, bool isReadOnly) + { if (!_configService.ImportExtraFiles) { return; @@ -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) { @@ -87,7 +87,7 @@ namespace NzbDrone.Core.Extras foreach (var extraFileManager in _extraFileManagers) { var extension = Path.GetExtension(matchingFilename); - var extraFile = extraFileManager.Import(series, episodeFile, matchingFilename, extension, isReadOnly); + var extraFile = extraFileManager.Import(localEpisode.Series, episodeFile, matchingFilename, extension, isReadOnly); if (extraFile != null) { @@ -102,6 +102,14 @@ namespace NzbDrone.Core.Extras } } + private void CreateAfterImport(Series series, EpisodeFile episodeFile) + { + foreach (var extraFileManager in _extraFileManagers) + { + extraFileManager.CreateAfterEpisodeImport(series, episodeFile); + } + } + public void Handle(MediaCoversUpdatedEvent message) { var series = message.Series; 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/Files/ExtraFileService.cs b/src/NzbDrone.Core/Extras/Files/ExtraFileService.cs index ac30f6536..50bd5f369 100644 --- a/src/NzbDrone.Core/Extras/Files/ExtraFileService.cs +++ b/src/NzbDrone.Core/Extras/Files/ExtraFileService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -27,7 +27,7 @@ namespace NzbDrone.Core.Extras.Files public abstract class ExtraFileService : IExtraFileService, IHandleAsync, - IHandleAsync + IHandle where TExtraFile : ExtraFile, new() { private readonly IExtraFileRepository _repository; @@ -49,8 +49,6 @@ namespace NzbDrone.Core.Extras.Files _logger = logger; } - public virtual bool PermanentlyDelete => false; - public List GetFilesBySeries(int seriesId) { return _repository.GetFilesBySeries(seriesId); @@ -103,7 +101,7 @@ namespace NzbDrone.Core.Extras.Files _repository.DeleteForSeries(message.Series.Id); } - public void HandleAsync(EpisodeFileDeletedEvent message) + public void Handle(EpisodeFileDeletedEvent message) { var episodeFile = message.EpisodeFile; @@ -122,17 +120,9 @@ namespace NzbDrone.Core.Extras.Files if (_diskProvider.FileExists(path)) { - if (PermanentlyDelete) - { - _diskProvider.DeleteFile(path); - } - - else - { - // Send extra files to the recycling bin so they can be recovered if necessary - var subfolder = _diskProvider.GetParentFolder(series.Path).GetRelativePath(_diskProvider.GetParentFolder(path)); - _recycleBinProvider.DeleteFile(path, subfolder); - } + // Send to the recycling bin so they can be recovered if necessary + var subfolder = _diskProvider.GetParentFolder(series.Path).GetRelativePath(_diskProvider.GetParentFolder(path)); + _recycleBinProvider.DeleteFile(path, subfolder); } } } 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..5126fbf96 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs @@ -7,24 +7,32 @@ using System.Text.RegularExpressions; using System.Xml; using System.Xml.Linq; using NLog; +using NzbDrone.Common.Disk; 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 { public class XbmcMetadata : MetadataBase { - private readonly IMapCoversToLocal _mediaCoverService; private readonly Logger _logger; + private readonly IMapCoversToLocal _mediaCoverService; + private readonly IDetectXbmcNfo _detectNfo; + private readonly IDiskProvider _diskProvider; - public XbmcMetadata(IMapCoversToLocal mediaCoverService, + public XbmcMetadata(IDetectXbmcNfo detectNfo, + IDiskProvider diskProvider, + IMapCoversToLocal mediaCoverService, Logger logger) { - _mediaCoverService = mediaCoverService; _logger = logger; + _mediaCoverService = mediaCoverService; + _diskProvider = diskProvider; + _detectNfo = detectNfo; } private static readonly Regex SeriesImagesRegex = new Regex(@"^(?poster|banner|fanart)\.(?:png|jpg)", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -103,7 +111,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 +121,8 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc if (parseResult != null && !parseResult.FullSeason && - Path.GetExtension(filename) == ".nfo") + Path.GetExtension(filename).Equals(".nfo", StringComparison.OrdinalIgnoreCase) && + _detectNfo.IsXbmcNfoFile(path)) { metadata.Type = MetadataType.EpisodeMetadata; return metadata; @@ -198,6 +207,8 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc _logger.Debug("Generating Episode Metadata for: {0}", Path.Combine(series.Path, episodeFile.RelativePath)); + var watched = GetExistingWatchedStatus(series, episodeFile.RelativePath); + var xmlResult = string.Empty; foreach (var episode in episodeFile.Episodes.Value) { @@ -232,7 +243,7 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc details.Add(new XElement("thumb", image.Url)); } - details.Add(new XElement("watched", "false")); + details.Add(new XElement("watched", watched)); if (episode.Ratings != null && episode.Ratings.Votes > 0) { @@ -241,17 +252,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 +277,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)); @@ -380,14 +393,18 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc return Path.ChangeExtension(episodeFilePath, "").Trim('.') + "-thumb.jpg"; } - private string GetAudioCodec(string audioCodec) + private bool GetExistingWatchedStatus(Series series, string episodeFilePath) { - if (audioCodec == "AC-3") + var fullPath = Path.Combine(series.Path, GetEpisodeMetadataFilename(episodeFilePath)); + + if (!_diskProvider.FileExists(fullPath)) { - return "AC3"; + return false; } - return audioCodec; + var fileContent = _diskProvider.ReadAllText(fullPath); + + return Regex.IsMatch(fileContent, "true"); } } } diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcNfoDetector.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcNfoDetector.cs new file mode 100644 index 000000000..405d0e7a3 --- /dev/null +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcNfoDetector.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using NzbDrone.Common.Disk; + +namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc +{ + public interface IDetectXbmcNfo + { + bool IsXbmcNfoFile(string path); + } + + public class XbmcNfoDetector : IDetectXbmcNfo + { + private readonly IDiskProvider _diskProvider; + + private readonly Regex _regex = new Regex("<(movie|tvshow|episodedetails|artist|album|musicvideo)>", RegexOptions.Compiled); + + public XbmcNfoDetector(IDiskProvider diskProvider) + { + _diskProvider = diskProvider; + } + + public bool IsXbmcNfoFile(string path) + { + // Lets make sure we're not reading huge files. + if (_diskProvider.GetFileSize(path) > 10.Megabytes()) + { + return false; + } + + // Check if it contains some of the kodi/xbmc xml tags + var content = _diskProvider.ReadAllText(path); + + return _regex.IsMatch(content); + } + } +} diff --git a/src/NzbDrone.Core/Extras/Metadata/ExistingMetadataImporter.cs b/src/NzbDrone.Core/Extras/Metadata/ExistingMetadataImporter.cs index fa271f575..b5250f6e6 100644 --- a/src/NzbDrone.Core/Extras/Metadata/ExistingMetadataImporter.cs +++ b/src/NzbDrone.Core/Extras/Metadata/ExistingMetadataImporter.cs @@ -6,7 +6,9 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Extras.Files; using NzbDrone.Core.Extras.Metadata.Files; using NzbDrone.Core.Extras.Subtitles; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation; using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Extras.Metadata @@ -14,18 +16,18 @@ namespace NzbDrone.Core.Extras.Metadata public class ExistingMetadataImporter : ImportExistingExtraFilesBase { private readonly IExtraFileService _metadataFileService; - private readonly IParsingService _parsingService; + private readonly IAugmentingService _augmentingService; private readonly Logger _logger; private readonly List _consumers; public ExistingMetadataImporter(IExtraFileService metadataFileService, IEnumerable consumers, - IParsingService parsingService, + IAugmentingService augmentingService, Logger logger) : base(metadataFileService) { _metadataFileService = metadataFileService; - _parsingService = parsingService; + _augmentingService = augmentingService; _logger = logger; _consumers = consumers.ToList(); } @@ -60,9 +62,18 @@ namespace NzbDrone.Core.Extras.Metadata if (metadata.Type == MetadataType.EpisodeImage || metadata.Type == MetadataType.EpisodeMetadata) { - var localEpisode = _parsingService.GetLocalEpisode(possibleMetadataFile, series); + var localEpisode = new LocalEpisode + { + FileEpisodeInfo = Parser.Parser.ParsePath(possibleMetadataFile), + Series = series, + Path = possibleMetadataFile + }; - if (localEpisode == null) + try + { + _augmentingService.Augment(localEpisode, false); + } + catch (AugmentingFailedException ex) { _logger.Debug("Unable to parse extra file: {0}", possibleMetadataFile); continue; diff --git a/src/NzbDrone.Core/Extras/Metadata/Files/MetadataFileService.cs b/src/NzbDrone.Core/Extras/Metadata/Files/MetadataFileService.cs index f5fc2ba69..b83bf0c90 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Files/MetadataFileService.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Files/MetadataFileService.cs @@ -16,7 +16,5 @@ namespace NzbDrone.Core.Extras.Metadata.Files : base(repository, seriesService, diskProvider, recycleBinProvider, logger) { } - - public override bool PermanentlyDelete => true; } } diff --git a/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs b/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs index dbe4c1fba..793662bcd 100644 --- a/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs +++ b/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs @@ -10,6 +10,7 @@ using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Extras.Files; using NzbDrone.Core.Extras.Metadata.Files; +using NzbDrone.Core.Extras.Others; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Tv; @@ -19,6 +20,8 @@ namespace NzbDrone.Core.Extras.Metadata { private readonly IMetadataFactory _metadataFactory; private readonly ICleanMetadataService _cleanMetadataService; + private readonly IRecycleBinProvider _recycleBinProvider; + private readonly IOtherExtraFileRenamer _otherExtraFileRenamer; private readonly IDiskTransferService _diskTransferService; private readonly IDiskProvider _diskProvider; private readonly IHttpClient _httpClient; @@ -29,6 +32,8 @@ namespace NzbDrone.Core.Extras.Metadata public MetadataService(IConfigService configService, IDiskProvider diskProvider, IDiskTransferService diskTransferService, + IRecycleBinProvider recycleBinProvider, + IOtherExtraFileRenamer otherExtraFileRenamer, IMetadataFactory metadataFactory, ICleanMetadataService cleanMetadataService, IHttpClient httpClient, @@ -39,6 +44,8 @@ namespace NzbDrone.Core.Extras.Metadata { _metadataFactory = metadataFactory; _cleanMetadataService = cleanMetadataService; + _otherExtraFileRenamer = otherExtraFileRenamer; + _recycleBinProvider = recycleBinProvider; _diskTransferService = diskTransferService; _diskProvider = diskProvider; _httpClient = httpClient; @@ -88,7 +95,6 @@ namespace NzbDrone.Core.Extras.Metadata foreach (var consumer in _metadataFactory.Enabled()) { - files.AddIfNotNull(ProcessEpisodeMetadata(consumer, series, episodeFile, new List())); files.AddRange(ProcessEpisodeImages(consumer, series, episodeFile, new List())); } @@ -235,6 +241,8 @@ namespace NzbDrone.Core.Extras.Metadata var fullPath = Path.Combine(series.Path, episodeMetadata.RelativePath); + _otherExtraFileRenamer.RenameOtherExtraFile(series, fullPath); + var existingMetadata = GetMetadataFile(series, existingMetadataFiles, c => c.Type == MetadataType.EpisodeMetadata && c.EpisodeFileId == episodeFile.Id); @@ -289,6 +297,8 @@ namespace NzbDrone.Core.Extras.Metadata continue; } + _otherExtraFileRenamer.RenameOtherExtraFile(series, fullPath); + var metadata = GetMetadataFile(series, existingMetadataFiles, c => c.Type == MetadataType.SeriesImage && c.RelativePath == image.RelativePath) ?? new MetadataFile @@ -324,6 +334,8 @@ namespace NzbDrone.Core.Extras.Metadata continue; } + _otherExtraFileRenamer.RenameOtherExtraFile(series, fullPath); + var metadata = GetMetadataFile(series, existingMetadataFiles, c => c.Type == MetadataType.SeasonImage && c.SeasonNumber == season.SeasonNumber && c.RelativePath == image.RelativePath) ?? @@ -360,6 +372,8 @@ namespace NzbDrone.Core.Extras.Metadata continue; } + _otherExtraFileRenamer.RenameOtherExtraFile(series, fullPath); + var existingMetadata = GetMetadataFile(series, existingMetadataFiles, c => c.Type == MetadataType.EpisodeImage && c.EpisodeFileId == episodeFile.Id); @@ -443,11 +457,11 @@ namespace NzbDrone.Core.Extras.Metadata _logger.Debug("Removing duplicate Metadata file: {0}", path); - _diskProvider.DeleteFile(path); + var subfolder = _diskProvider.GetParentFolder(series.Path).GetRelativePath(_diskProvider.GetParentFolder(path)); + _recycleBinProvider.DeleteFile(path, subfolder); _metadataFileService.Delete(file.Id); } - return matchingMetadataFiles.First(); } } diff --git a/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs b/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs index 05afb5645..724f4968c 100644 --- a/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs +++ b/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs @@ -1,10 +1,13 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Extras.Files; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation; using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Extras.Others @@ -12,16 +15,16 @@ namespace NzbDrone.Core.Extras.Others public class ExistingOtherExtraImporter : ImportExistingExtraFilesBase { private readonly IExtraFileService _otherExtraFileService; - private readonly IParsingService _parsingService; + private readonly IAugmentingService _augmentingService; private readonly Logger _logger; public ExistingOtherExtraImporter(IExtraFileService otherExtraFileService, - IParsingService parsingService, + IAugmentingService augmentingService, Logger logger) : base(otherExtraFileService) { _otherExtraFileService = otherExtraFileService; - _parsingService = parsingService; + _augmentingService = augmentingService; _logger = logger; } @@ -44,9 +47,18 @@ namespace NzbDrone.Core.Extras.Others continue; } - var localEpisode = _parsingService.GetLocalEpisode(possibleExtraFile, series); + var localEpisode = new LocalEpisode + { + FileEpisodeInfo = Parser.Parser.ParsePath(possibleExtraFile), + Series = series, + Path = possibleExtraFile + }; - if (localEpisode == null) + try + { + _augmentingService.Augment(localEpisode, false); + } + catch (AugmentingFailedException ex) { _logger.Debug("Unable to parse extra file: {0}", possibleExtraFile); continue; diff --git a/src/NzbDrone.Core/Extras/Others/OtherExtraFileRenamer.cs b/src/NzbDrone.Core/Extras/Others/OtherExtraFileRenamer.cs new file mode 100644 index 000000000..ff4df604f --- /dev/null +++ b/src/NzbDrone.Core/Extras/Others/OtherExtraFileRenamer.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Extras.Others +{ + public interface IOtherExtraFileRenamer + { + void RenameOtherExtraFile(Series series, string path); + } + + public class OtherExtraFileRenamer : IOtherExtraFileRenamer + { + private readonly Logger _logger; + private readonly IDiskProvider _diskProvider; + private readonly IRecycleBinProvider _recycleBinProvider; + private readonly ISeriesService _seriesService; + private readonly IOtherExtraFileService _otherExtraFileService; + + public OtherExtraFileRenamer(IOtherExtraFileService otherExtraFileService, + ISeriesService seriesService, + IRecycleBinProvider recycleBinProvider, + IDiskProvider diskProvider, + Logger logger) + { + _logger = logger; + _diskProvider = diskProvider; + _recycleBinProvider = recycleBinProvider; + _seriesService = seriesService; + _otherExtraFileService = otherExtraFileService; + } + + public void RenameOtherExtraFile(Series series, string path) + { + if (!_diskProvider.FileExists(path)) + { + return; + } + + var relativePath = series.Path.GetRelativePath(path); + + var otherExtraFile = _otherExtraFileService.FindByPath(relativePath); + if (otherExtraFile != null) + { + var newPath = path + "-orig"; + + // Recycle an existing -orig file. + RemoveOtherExtraFile(series, newPath); + + // Rename the file to .*-orig + _diskProvider.MoveFile(path, newPath); + otherExtraFile.RelativePath = relativePath + "-orig"; + otherExtraFile.Extension += "-orig"; + _otherExtraFileService.Upsert(otherExtraFile); + } + } + + private void RemoveOtherExtraFile(Series series, string path) + { + if (!_diskProvider.FileExists(path)) + { + return; + } + + var relativePath = series.Path.GetRelativePath(path); + + var otherExtraFile = _otherExtraFileService.FindByPath(relativePath); + if (otherExtraFile != null) + { + var subfolder = Path.GetDirectoryName(relativePath); + _recycleBinProvider.DeleteFile(path, subfolder); + } + } + } +} diff --git a/src/NzbDrone.Core/Extras/Others/OtherExtraService.cs b/src/NzbDrone.Core/Extras/Others/OtherExtraService.cs index f3e331c30..62d9f5129 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; @@ -64,12 +65,6 @@ 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")) - { - extension += "-orig"; - } - var extraFile = ImportFile(series, episodeFile, path, readOnly, extension, null); _otherExtraFileService.Upsert(extraFile); diff --git a/src/NzbDrone.Core/Extras/Subtitles/ExistingSubtitleImporter.cs b/src/NzbDrone.Core/Extras/Subtitles/ExistingSubtitleImporter.cs index d3ae8d46b..c15732351 100644 --- a/src/NzbDrone.Core/Extras/Subtitles/ExistingSubtitleImporter.cs +++ b/src/NzbDrone.Core/Extras/Subtitles/ExistingSubtitleImporter.cs @@ -4,7 +4,9 @@ using System.Linq; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Extras.Files; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation; using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Extras.Subtitles @@ -12,16 +14,16 @@ namespace NzbDrone.Core.Extras.Subtitles public class ExistingSubtitleImporter : ImportExistingExtraFilesBase { private readonly IExtraFileService _subtitleFileService; - private readonly IParsingService _parsingService; + private readonly IAugmentingService _augmentingService; private readonly Logger _logger; public ExistingSubtitleImporter(IExtraFileService subtitleFileService, - IParsingService parsingService, + IAugmentingService augmentingService, Logger logger) : base (subtitleFileService) { _subtitleFileService = subtitleFileService; - _parsingService = parsingService; + _augmentingService = augmentingService; _logger = logger; } @@ -40,11 +42,20 @@ namespace NzbDrone.Core.Extras.Subtitles if (SubtitleFileExtensions.Extensions.Contains(extension)) { - var localEpisode = _parsingService.GetLocalEpisode(possibleSubtitleFile, series); - - if (localEpisode == null) + var localEpisode = new LocalEpisode { - _logger.Debug("Unable to parse subtitle file: {0}", possibleSubtitleFile); + FileEpisodeInfo = Parser.Parser.ParsePath(possibleSubtitleFile), + Series = series, + Path = possibleSubtitleFile + }; + + try + { + _augmentingService.Augment(localEpisode, false); + } + catch (AugmentingFailedException ex) + { + _logger.Debug("Unable to parse extra file: {0}", possibleSubtitleFile); continue; } 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..7e7b9d259 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/CheckOnAttribute.cs @@ -0,0 +1,24 @@ +using System; + +namespace NzbDrone.Core.HealthCheck +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public class CheckOnAttribute: Attribute + { + public Type EventType { get; set; } + public CheckOnCondition Condition { get; set; } + + public CheckOnAttribute(Type eventType, CheckOnCondition condition = CheckOnCondition.Always) + { + EventType = eventType; + Condition = condition; + } + } + + public enum CheckOnCondition + { + Always, + FailedOnly, + SuccessfulOnly + } +} 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/MonoDebugCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/MonoDebugCheck.cs new file mode 100644 index 000000000..3296c199f --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/MonoDebugCheck.cs @@ -0,0 +1,47 @@ +using System.Diagnostics; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + public class MonoDebugCheck : HealthCheckBase + { + private readonly Logger _logger; + private readonly StackFrameHelper _stackFrameHelper; + + public override bool CheckOnSchedule => false; + + public MonoDebugCheck(Logger logger, StackFrameHelper stackFrameHelper) + { + _logger = logger; + _stackFrameHelper = stackFrameHelper; + } + + public class StackFrameHelper + { + public virtual bool HasStackFrameInfo() + { + var stackTrace = new StackTrace(true); + + return stackTrace.FrameCount > 0 && stackTrace.GetFrame(0).GetFileName().IsNotNullOrWhiteSpace(); + } + } + + public override HealthCheck Check() + { + if (!PlatformInfo.IsMono) + { + return new HealthCheck(GetType()); + } + + if (!_stackFrameHelper.HasStackFrameInfo()) + { + _logger.Warn("Mono is not running with --debug switch"); + return new HealthCheck(GetType()); + } + + return new HealthCheck(GetType()); + } + } +} 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..f5605f661 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs @@ -1,9 +1,15 @@ -using System.Linq; +using System.Linq; using NzbDrone.Common.Disk; +using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Tv; +using NzbDrone.Core.Tv.Events; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(SeriesDeletedEvent))] + [CheckOn(typeof(SeriesMovedEvent))] + [CheckOn(typeof(EpisodeImportedEvent), CheckOnCondition.FailedOnly)] + [CheckOn(typeof(EpisodeImportFailedEvent), CheckOnCondition.SuccessfulOnly)] public class RootFolderCheck : HealthCheckBase { private readonly ISeriesService _seriesService; @@ -36,7 +42,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/EventDrivenHealthCheck.cs b/src/NzbDrone.Core/HealthCheck/EventDrivenHealthCheck.cs new file mode 100644 index 000000000..0b55c1ff2 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/EventDrivenHealthCheck.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.HealthCheck +{ + public class EventDrivenHealthCheck + { + public IProvideHealthCheck HealthCheck { get; set; } + public CheckOnCondition Condition { get; set; } + + public EventDrivenHealthCheck(IProvideHealthCheck healthCheck, CheckOnCondition condition) + { + HealthCheck = healthCheck; + Condition = condition; + } + } +} 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..f9c35d515 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, new EventDrivenHealthCheck(h, a.Condition)))) + .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,65 @@ 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); - } + EventDrivenHealthCheck[] checks; + if (!_eventDrivenHealthChecks.TryGetValue(message.GetType(), out checks)) + { + return; + } - public void HandleAsync(ProviderDeletedEvent message) - { - PerformHealthCheck(c => c.CheckOnConfigChange); - } + var filteredChecks = new List(); + var healthCheckResults = _healthCheckResults.Values.ToList(); - public void HandleAsync(ProviderUpdatedEvent message) - { - PerformHealthCheck(c => c.CheckOnConfigChange); - } + foreach (var eventDrivenHealthCheck in checks) + { + if (eventDrivenHealthCheck.Condition == CheckOnCondition.Always) + { + filteredChecks.Add(eventDrivenHealthCheck.HealthCheck); + continue; + } - public void HandleAsync(ProviderDeletedEvent message) - { - PerformHealthCheck(c => c.CheckOnConfigChange); + var healthCheckType = eventDrivenHealthCheck.HealthCheck.GetType(); + + if (eventDrivenHealthCheck.Condition == CheckOnCondition.FailedOnly && + healthCheckResults.Any(r => r.Source == healthCheckType)) + { + filteredChecks.Add(eventDrivenHealthCheck.HealthCheck); + continue; + } + + if (eventDrivenHealthCheck.Condition == CheckOnCondition.SuccessfulOnly && + healthCheckResults.None(r => r.Source == healthCheckType)) + { + filteredChecks.Add(eventDrivenHealthCheck.HealthCheck); + } + } + + + // TODO: Add debounce + + PerformHealthCheck(filteredChecks.ToArray()); } } } 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/HistoryRepository.cs b/src/NzbDrone.Core/History/HistoryRepository.cs index 35199a878..bfc1aeb85 100644 --- a/src/NzbDrone.Core/History/HistoryRepository.cs +++ b/src/NzbDrone.Core/History/HistoryRepository.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Marr.Data.QGen; using NzbDrone.Core.Datastore; @@ -16,6 +17,7 @@ namespace NzbDrone.Core.History List FindByDownloadId(string downloadId); List FindDownloadHistory(int idSeriesId, QualityModel quality); void DeleteForSeries(int seriesId); + List Since(DateTime date, HistoryEventType? eventType); } public class HistoryRepository : BasicRepository, IHistoryRepository @@ -76,5 +78,19 @@ namespace NzbDrone.Core.History return base.GetPagedQuery(baseQuery, pagingSpec); } + + public List Since(DateTime date, HistoryEventType? eventType) + { + var query = Query.Where(h => h.Date >= date); + + if (eventType.HasValue) + { + query.AndWhere(h => h.EventType == eventType); + } + + query.OrderBy(h => h.Date); + + return query; + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index 32815beef..0f835b5a4 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -25,6 +25,7 @@ namespace NzbDrone.Core.History History Get(int historyId); List Find(string downloadId, HistoryEventType eventType); List FindByDownloadId(string downloadId); + List Since(DateTime date, HistoryEventType? eventType); } public class HistoryService : IHistoryService, @@ -32,6 +33,7 @@ namespace NzbDrone.Core.History IHandle, IHandle, IHandle, + IHandle, IHandle { private readonly IHistoryRepository _historyRepository; @@ -257,9 +259,42 @@ 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); } + + public List Since(DateTime date, HistoryEventType? eventType) + { + return _historyRepository.Since(date, eventType); + } } -} \ No newline at end of file +} 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/Housekeeping/Housekeepers/FixFutureDownloadClientStatusTimes.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureDownloadClientStatusTimes.cs new file mode 100644 index 000000000..58361e0b7 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureDownloadClientStatusTimes.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Download; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class FixFutureDownloadClientStatusTimes : FixFutureProviderStatusTimes, IHousekeepingTask + { + public FixFutureDownloadClientStatusTimes(IDownloadClientStatusRepository downloadClientStatusRepository) + : base(downloadClientStatusRepository) + { + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureIndexerStatusTimes.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureIndexerStatusTimes.cs new file mode 100644 index 000000000..f635698d5 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureIndexerStatusTimes.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Indexers; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class FixFutureIndexerStatusTimes : FixFutureProviderStatusTimes, IHousekeepingTask + { + public FixFutureIndexerStatusTimes(IIndexerStatusRepository indexerStatusRepository) + : base(indexerStatusRepository) + { + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureProviderStatusTimes.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureProviderStatusTimes.cs new file mode 100644 index 000000000..80bf5c8b9 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureProviderStatusTimes.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public abstract class FixFutureProviderStatusTimes where TModel : ProviderStatusBase, new() + { + private readonly IProviderStatusRepository _repo; + + protected FixFutureProviderStatusTimes(IProviderStatusRepository repo) + { + _repo = repo; + } + + public void Clean() + { + var now = DateTime.UtcNow; + var statuses = _repo.All().ToList(); + var toUpdate = new List(); + + foreach (var status in statuses) + { + var updated = false; + var escalationDelay = EscalationBackOff.Periods[status.EscalationLevel]; + var disabledTill = now.AddMinutes(escalationDelay); + + if (status.DisabledTill > disabledTill) + { + status.DisabledTill = disabledTill; + updated = true; + } + + if (status.InitialFailure > now) + { + status.InitialFailure = now; + updated = true; + } + + if (status.MostRecentFailure > now) + { + status.MostRecentFailure = now; + updated = true; + } + + if (updated) + { + toUpdate.Add(status); + } + } + + _repo.UpdateMany(toUpdate); + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/DailyEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/DailyEpisodeSearchCriteria.cs index d5eeb15b7..63a91d1c0 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/DailyEpisodeSearchCriteria.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/DailyEpisodeSearchCriteria.cs @@ -8,7 +8,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions public override string ToString() { - return string.Format("[{0} : {1:yyyy-MM-dd}", Series.Title, AirDate); + return string.Format("[{0} : {1:yyyy-MM-dd}]", Series.Title, AirDate); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/DailySeasonSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/DailySeasonSearchCriteria.cs new file mode 100644 index 000000000..503e3ca6b --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/DailySeasonSearchCriteria.cs @@ -0,0 +1,14 @@ +using System; + +namespace NzbDrone.Core.IndexerSearch.Definitions +{ + public class DailySeasonSearchCriteria : SearchCriteriaBase + { + public int Year { get; set; } + + public override string ToString() + { + return string.Format("[{0} : {1}]", Series.Title, Year); + } + } +} 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..5762c3c6a 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; @@ -12,6 +12,7 @@ using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tv; using System.Linq; using NzbDrone.Common.TPL; +using NzbDrone.Common.Extensions; namespace NzbDrone.Core.IndexerSearch { @@ -73,7 +74,7 @@ namespace NzbDrone.Core.IndexerSearch if (episode.SeasonNumber == 0) { - // search for special episodes in season 0 + // search for special episodes in season 0 return SearchSpecial(series, new List { episode }, userInvokedSearch); } @@ -95,9 +96,14 @@ namespace NzbDrone.Core.IndexerSearch return SearchAnimeSeason(series, episodes, userInvokedSearch); } + if (series.SeriesType == SeriesTypes.Daily) + { + return SearchDailySeason(series, episodes, userInvokedSearch); + } + if (seasonNumber == 0) { - // search for special episodes in season 0 + // search for special episodes in season 0 return SearchSpecial(series, episodes, userInvokedSearch); } @@ -199,7 +205,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); @@ -228,6 +234,30 @@ namespace NzbDrone.Core.IndexerSearch return downloadDecisions; } + private List SearchDailySeason(Series series, List episodes, bool userInvokedSearch) + { + var downloadDecisions = new List(); + foreach (var yearGroup in episodes.Where(v => v.Monitored && v.AirDate.IsNotNullOrWhiteSpace()) + .GroupBy(v => DateTime.ParseExact(v.AirDate, Episode.AIR_DATE_FORMAT, CultureInfo.InvariantCulture).Year)) + { + var yearEpisodes = yearGroup.ToList(); + + if (yearEpisodes.Count > 1) + { + var searchSpec = Get(series, yearEpisodes, userInvokedSearch); + searchSpec.Year = yearGroup.Key; + + downloadDecisions.AddRange(Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec)); + } + else + { + downloadDecisions.AddRange(SearchDaily(series, yearEpisodes.First(), userInvokedSearch)); + } + } + + return downloadDecisions; + } + private TSpec Get(Series series, List episodes, bool userInvokedSearch) where TSpec : SearchCriteriaBase, new() { var spec = new TSpec(); @@ -237,9 +267,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/BitMeTvRequestGenerator.cs b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvRequestGenerator.cs index e7966dcba..efa19c679 100644 --- a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvRequestGenerator.cs @@ -7,7 +7,7 @@ namespace NzbDrone.Core.Indexers.BitMeTv public class BitMeTvRequestGenerator : IIndexerRequestGenerator { public BitMeTvSettings Settings { get; set; } - + public virtual IndexerPageableRequestChain GetRecentRequests() { var pageableRequests = new IndexerPageableRequestChain(); @@ -32,6 +32,11 @@ namespace NzbDrone.Core.Indexers.BitMeTv return new IndexerPageableRequestChain(); } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } + public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) { return new IndexerPageableRequestChain(); diff --git a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs index 6e48f46de..babdd3beb 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 @@ -20,16 +19,19 @@ namespace NzbDrone.Core.Indexers.BitMeTv .Matches(@"pass=[0-9a-f]{32}", RegexOptions.IgnoreCase) .WithMessage("Wrong pattern") .AsWarning(); + + RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator()); } } - 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 +46,15 @@ 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; } + + [FieldDefinition(5)] + public SeedCriteriaSettings SeedCriteria { get; } = new SeedCriteriaSettings(); + 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..0dd19ab44 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" @@ -56,14 +56,17 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet pageableRequests.Add(GetPagedRequests(MaxPages, parameters)); } - foreach (var seasonNumber in searchCriteria.Episodes.Select(v => v.SeasonNumber).Distinct()) + if (searchCriteria.UserInvokedSearch) { - parameters = parameters.Clone(); + foreach (var seasonNumber in searchCriteria.Episodes.Select(v => v.SeasonNumber).Distinct()) + { + parameters = parameters.Clone(); - parameters.Category = "Season"; - parameters.Name = string.Format("Season {0}", seasonNumber); + parameters.Category = "Season"; + parameters.Name = string.Format("Season {0}%", seasonNumber); - pageableRequests.Add(GetPagedRequests(MaxPages, parameters)); + pageableRequests.Add(GetPagedRequests(MaxPages, parameters)); + } } } @@ -80,7 +83,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)); @@ -124,6 +127,34 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet return pageableRequests; } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + + var parameters = new BroadcastheNetTorrentQuery(); + if (AddSeriesSearchParameters(parameters, searchCriteria)) + { + parameters.Category = "Episode"; + parameters.Name = string.Format("{0}%", searchCriteria.Year); + + pageableRequests.Add(GetPagedRequests(MaxPages, parameters)); + + pageableRequests.AddTier(); + + foreach (var episode in searchCriteria.Episodes) + { + parameters = parameters.Clone(); + + parameters.Category = "Episode"; + parameters.Name = string.Format("S{0:00}E{1:00}", episode.SeasonNumber, episode.EpisodeNumber); + + pageableRequests.Add(GetPagedRequests(MaxPages, parameters)); + } + } + + return pageableRequests; + } + public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); @@ -146,7 +177,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..7d3853216 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 @@ -11,16 +10,19 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet { RuleFor(c => c.BaseUrl).ValidRootUrl(); RuleFor(c => c.ApiKey).NotEmpty(); + + RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator(1.0, 24*60, 5*24*60)); } } - 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 +31,12 @@ 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; } + + [FieldDefinition(3)] + public SeedCriteriaSettings SeedCriteria { get; } = new SeedCriteriaSettings(); + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Indexers/Fanzub/FanzubRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Fanzub/FanzubRequestGenerator.cs index 19585dad5..47a96b334 100644 --- a/src/NzbDrone.Core/Indexers/Fanzub/FanzubRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Fanzub/FanzubRequestGenerator.cs @@ -44,6 +44,11 @@ namespace NzbDrone.Core.Indexers.Fanzub return new IndexerPageableRequestChain(); } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } + public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); 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/FetchAndParseRssService.cs b/src/NzbDrone.Core/Indexers/FetchAndParseRssService.cs index e114f844d..6e23f1917 100644 --- a/src/NzbDrone.Core/Indexers/FetchAndParseRssService.cs +++ b/src/NzbDrone.Core/Indexers/FetchAndParseRssService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using NLog; @@ -52,6 +52,8 @@ namespace NzbDrone.Core.Indexers lock (result) { + _logger.Debug("Found {0} from {1}", indexerReports.Count, indexer.Name); + result.AddRange(indexerReports); } } @@ -71,4 +73,4 @@ namespace NzbDrone.Core.Indexers return result; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs index dacb87490..fead12419 100644 --- a/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs @@ -58,6 +58,21 @@ namespace NzbDrone.Core.Indexers.HDBits return pageableRequests; } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + + var query = new TorrentQuery(); + if (TryAddSearchParameters(query, searchCriteria)) + { + query.Search = string.Format("{0}-", searchCriteria.Year); + + pageableRequests.Add(GetRequest(query)); + } + + return pageableRequests; + } + public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); diff --git a/src/NzbDrone.Core/Indexers/HDBits/HDBitsSettings.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBitsSettings.cs index 933a134d2..48fd48783 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 @@ -11,16 +10,19 @@ namespace NzbDrone.Core.Indexers.HDBits { RuleFor(c => c.BaseUrl).ValidRootUrl(); RuleFor(c => c.ApiKey).NotEmpty(); + + RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator()); } } - 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 +34,12 @@ 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; } + + [FieldDefinition(4)] + public SeedCriteriaSettings SeedCriteria { get; } = new SeedCriteriaSettings(); + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); @@ -55,7 +63,8 @@ namespace NzbDrone.Core.Indexers.HDBits H264 = 1, Mpeg2 = 2, Vc1 = 3, - Xvid = 4 + Xvid = 4, + Hevc = 5 } public enum HdBitsMedium diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs index b88158b14..ce63250eb 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,17 @@ namespace NzbDrone.Core.Indexers return new List(); } - var generator = GetRequestGenerator(); + return FetchReleases(g => g.GetSearchRequests(searchCriteria)); + } - return FetchReleases(generator.GetSearchRequests(searchCriteria)); + public override IList Fetch(DailySeasonSearchCriteria searchCriteria) + { + if (!SupportsSearch) + { + return new List(); + } + + return FetchReleases(g => g.GetSearchRequests(searchCriteria)); } public override IList Fetch(AnimeEpisodeSearchCriteria searchCriteria) @@ -94,9 +96,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 +106,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 +176,7 @@ namespace NzbDrone.Core.Indexers } } - releases.AddRange(pagedReleases); + releases.AddRange(pagedReleases.Where(IsValidRelease)); } if (releases.Any()) @@ -222,18 +223,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 +253,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 +268,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; @@ -281,7 +297,16 @@ namespace NzbDrone.Core.Indexers { var response = FetchIndexerResponse(request); - return parser.ParseResponse(response).ToList(); + try + { + return parser.ParseResponse(response).ToList(); + } + catch (Exception ex) + { + ex.WithData(response.HttpResponse, 128*1024); + _logger.Trace("Unexpected Response content ({0} bytes): {1}", response.HttpResponse.ResponseData.Length, response.HttpResponse.Content); + throw; + } } protected virtual IndexerResponse FetchIndexerResponse(IndexerRequest request) diff --git a/src/NzbDrone.Core/Indexers/IIndexer.cs b/src/NzbDrone.Core/Indexers/IIndexer.cs index 9f028b569..e306756a7 100644 --- a/src/NzbDrone.Core/Indexers/IIndexer.cs +++ b/src/NzbDrone.Core/Indexers/IIndexer.cs @@ -10,12 +10,13 @@ namespace NzbDrone.Core.Indexers bool SupportsRss { get; } bool SupportsSearch { get; } DownloadProtocol Protocol { get; } - + IList FetchRecent(); IList Fetch(SeasonSearchCriteria searchCriteria); IList Fetch(SingleEpisodeSearchCriteria searchCriteria); IList Fetch(DailyEpisodeSearchCriteria searchCriteria); + IList Fetch(DailySeasonSearchCriteria searchCriteria); IList Fetch(AnimeEpisodeSearchCriteria searchCriteria); IList Fetch(SpecialEpisodeSearchCriteria searchCriteria); } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/IIndexerRequestGenerator.cs b/src/NzbDrone.Core/Indexers/IIndexerRequestGenerator.cs index 5ad2cc79e..1f167f98f 100644 --- a/src/NzbDrone.Core/Indexers/IIndexerRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/IIndexerRequestGenerator.cs @@ -8,7 +8,8 @@ namespace NzbDrone.Core.Indexers IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria); IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria); IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria); + IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria); IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria); IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria); } -} \ No newline at end of file +} 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..c352d072c 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(); @@ -32,6 +32,11 @@ namespace NzbDrone.Core.Indexers.IPTorrents return new IndexerPageableRequestChain(); } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } + public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) { return new IndexerPageableRequestChain(); @@ -44,7 +49,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..52255d8fb 100644 --- a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsSettings.cs +++ b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsSettings.cs @@ -1,8 +1,7 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using FluentValidation; using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.IPTorrents @@ -11,26 +10,35 @@ 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\?.+$")); + + RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator()); } } - 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; } + + [FieldDefinition(2)] + public SeedCriteriaSettings SeedCriteria { get; } = new SeedCriteriaSettings(); 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..6915bc966 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/ITorrentIndexerSettings.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Indexers +{ + public interface ITorrentIndexerSettings : IIndexerSettings + { + int MinimumSeeders { get; set; } + + SeedCriteriaSettings SeedCriteria { get; } + } +} diff --git a/src/NzbDrone.Core/Indexers/IndexerBase.cs b/src/NzbDrone.Core/Indexers/IndexerBase.cs index 4e08e5aad..96638a419 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; @@ -65,6 +65,7 @@ namespace NzbDrone.Core.Indexers public abstract IList Fetch(SeasonSearchCriteria searchCriteria); public abstract IList Fetch(SingleEpisodeSearchCriteria searchCriteria); public abstract IList Fetch(DailyEpisodeSearchCriteria searchCriteria); + public abstract IList Fetch(DailySeasonSearchCriteria searchCriteria); public abstract IList Fetch(AnimeEpisodeSearchCriteria searchCriteria); public abstract IList Fetch(SpecialEpisodeSearchCriteria searchCriteria); @@ -96,11 +97,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..08baa51a5 100644 --- a/src/NzbDrone.Core/Indexers/IndexerStatusService.cs +++ b/src/NzbDrone.Core/Indexers/IndexerStatusService.cs @@ -1,153 +1,39 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; +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..79c40b997 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://feed.animetosho.org", apiPath: @"/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..c01852e10 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; } @@ -142,6 +146,17 @@ namespace NzbDrone.Core.Indexers.Newznab return pageableRequests; } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + + AddTvIdPageableRequests(pageableRequests, MaxPages, Settings.Categories, searchCriteria, + string.Format("&season={0}", + searchCriteria.Year)); + + return pageableRequests; + } + public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); @@ -249,7 +264,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/NyaaRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs index b54f4576f..2c8267cd3 100644 --- a/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs @@ -41,6 +41,11 @@ namespace NzbDrone.Core.Indexers.Nyaa return new IndexerPageableRequestChain(); } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } + public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); diff --git a/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs b/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs index 5977c2782..9213084e5 100644 --- a/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs +++ b/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs @@ -1,8 +1,8 @@ using FluentValidation; using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; using System.Text.RegularExpressions; + namespace NzbDrone.Core.Indexers.Nyaa { public class NyaaSettingsValidator : AbstractValidator @@ -11,17 +11,20 @@ namespace NzbDrone.Core.Indexers.Nyaa { RuleFor(c => c.BaseUrl).ValidRootUrl(); RuleFor(c => c.AdditionalParameters).Matches("(&[a-z]+=[a-z0-9_]+)*", RegexOptions.IgnoreCase); + + RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator()); } } - 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 +33,15 @@ 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; } + + [FieldDefinition(3)] + public SeedCriteriaSettings SeedCriteria { get; } = new SeedCriteriaSettings(); + 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..19d1f46a8 100644 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs @@ -68,6 +68,20 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs return pageableRequests; } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + + foreach (var queryTitle in searchCriteria.QueryTitles) + { + pageableRequests.Add(GetPagedRequests(string.Format("{0}+{1}", + queryTitle, + searchCriteria.Year))); + } + + return pageableRequests; + } + public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) { return new IndexerPageableRequestChain(); @@ -91,7 +105,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/RarbgRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Rarbg/RarbgRequestGenerator.cs index c4e683681..677e30c8e 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/RarbgRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/RarbgRequestGenerator.cs @@ -52,6 +52,15 @@ namespace NzbDrone.Core.Indexers.Rarbg return pageableRequests; } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.Add(GetPagedRequests("search", searchCriteria.Series.TvdbId, "\"{0}\"", searchCriteria.Year)); + + return pageableRequests; + } + public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) { return new IndexerPageableRequestChain(); diff --git a/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs b/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs index c60616b27..9a363741a 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 @@ -10,10 +9,12 @@ namespace NzbDrone.Core.Indexers.Rarbg public RarbgSettingsValidator() { RuleFor(c => c.BaseUrl).ValidRootUrl(); + + RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator()); } } - public class RarbgSettings : IProviderConfig + public class RarbgSettings : ITorrentIndexerSettings { private static readonly RarbgSettingsValidator Validator = new RarbgSettingsValidator(); @@ -21,6 +22,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 +30,19 @@ 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; } + + [FieldDefinition(4)] + public SeedCriteriaSettings SeedCriteria { get; } = new SeedCriteriaSettings(); + 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/RssIndexerRequestGenerator.cs b/src/NzbDrone.Core/Indexers/RssIndexerRequestGenerator.cs index 2ae5d4ed4..c6b4c57d8 100644 --- a/src/NzbDrone.Core/Indexers/RssIndexerRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/RssIndexerRequestGenerator.cs @@ -37,6 +37,11 @@ namespace NzbDrone.Core.Indexers return new IndexerPageableRequestChain(); } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } + public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) { return new IndexerPageableRequestChain(); diff --git a/src/NzbDrone.Core/Indexers/RssParser.cs b/src/NzbDrone.Core/Indexers/RssParser.cs index 4a4919288..b9bfa7000 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) @@ -299,6 +343,11 @@ namespace NzbDrone.Core.Indexers public static long ParseSize(string sizeString, bool defaultToBinaryPrefix) { + if (sizeString.IsNullOrWhiteSpace()) + { + return 0; + } + if (sizeString.All(char.IsDigit)) { return long.Parse(sizeString); diff --git a/src/NzbDrone.Core/Indexers/SeedConfigProvider.cs b/src/NzbDrone.Core/Indexers/SeedConfigProvider.cs new file mode 100644 index 000000000..ff07ec2b3 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/SeedConfigProvider.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Indexers +{ + public interface ISeedConfigProvider + { + TorrentSeedConfiguration GetSeedConfiguration(RemoteEpisode release); + } + + public class SeedConfigProvider : ISeedConfigProvider + { + private readonly IIndexerFactory _indexerFactory; + + public SeedConfigProvider(IIndexerFactory indexerFactory) + { + _indexerFactory = indexerFactory; + } + + public TorrentSeedConfiguration GetSeedConfiguration(RemoteEpisode remoteEpisode) + { + if (remoteEpisode.Release.DownloadProtocol != DownloadProtocol.Torrent) return null; + if (remoteEpisode.Release.IndexerId == 0) return null; + + try + { + var indexer = _indexerFactory.Get(remoteEpisode.Release.IndexerId); + var torrentIndexerSettings = indexer.Settings as ITorrentIndexerSettings; + + if (torrentIndexerSettings != null && torrentIndexerSettings.SeedCriteria != null) + { + var seedConfig = new TorrentSeedConfiguration + { + Ratio = torrentIndexerSettings.SeedCriteria.SeedRatio + }; + + var seedTime = remoteEpisode.ParsedEpisodeInfo.FullSeason ? torrentIndexerSettings.SeedCriteria.SeasonPackSeedTime : torrentIndexerSettings.SeedCriteria.SeedTime; + if (seedTime.HasValue) + { + seedConfig.SeedTime = TimeSpan.FromMinutes(seedTime.Value); + } + + return seedConfig; + } + } + catch (ModelNotFoundException) + { + return null; + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/SeedCriteriaSettings.cs b/src/NzbDrone.Core/Indexers/SeedCriteriaSettings.cs new file mode 100644 index 000000000..815cee782 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/SeedCriteriaSettings.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers +{ + public class SeedCriteriaSettingsValidator : AbstractValidator + { + public SeedCriteriaSettingsValidator(double seedRatioMinimum = 0.0, int seedTimeMinimum = 0, int seasonPackSeedTimeMinimum = 0) + { + RuleFor(c => c.SeedRatio).GreaterThan(0.0) + .When(c => c.SeedRatio.HasValue) + .AsWarning().WithMessage("Should be greater than zero"); + + RuleFor(c => c.SeedTime).GreaterThan(0) + .When(c => c.SeedTime.HasValue) + .AsWarning().WithMessage("Should be greater than zero"); + + RuleFor(c => c.SeasonPackSeedTime).GreaterThan(0) + .When(c => c.SeasonPackSeedTime.HasValue) + .AsWarning().WithMessage("Should be greater than zero"); + + if (seedRatioMinimum != 0.0) + { + RuleFor(c => c.SeedRatio).GreaterThanOrEqualTo(seedRatioMinimum) + .When(c => c.SeedRatio > 0.0) + .AsWarning() + .WithMessage($"Under {seedRatioMinimum} leads to H&R"); + } + + if (seedTimeMinimum != 0) + { + RuleFor(c => c.SeedTime).GreaterThanOrEqualTo(seedTimeMinimum) + .When(c => c.SeedTime > 0) + .AsWarning() + .WithMessage($"Under {seedTimeMinimum} leads to H&R"); + } + + if (seasonPackSeedTimeMinimum != 0) + { + RuleFor(c => c.SeasonPackSeedTime).GreaterThanOrEqualTo(seasonPackSeedTimeMinimum) + .When(c => c.SeasonPackSeedTime > 0) + .AsWarning() + .WithMessage($"Under {seasonPackSeedTimeMinimum} leads to H&R"); + } + } + } + + public class SeedCriteriaSettings + { + private static readonly SeedCriteriaSettingsValidator Validator = new SeedCriteriaSettingsValidator(); + + [FieldDefinition(0, Type = FieldType.Textbox, Label = "Seed Ratio", HelpText = "The ratio a torrent should reach before stopping, empty is download client's default", Advanced = true)] + public double? SeedRatio { get; set; } + + [FieldDefinition(1, Type = FieldType.Textbox, Label = "Seed Time", Unit = "minutes", HelpText = "The time a torrent should be seeded before stopping, empty is download client's default", Advanced = true)] + public int? SeedTime { get; set; } + + [FieldDefinition(2, Type = FieldType.Textbox, Label = "Season-Pack Seed Time", Unit = "minutes", HelpText = "The time a torrent should be seeded before stopping, empty is download client's default", Advanced = true)] + public int? SeasonPackSeedTime { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerRequestGenerator.cs b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerRequestGenerator.cs index a0bf58cbc..64df2ca9b 100644 --- a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerRequestGenerator.cs @@ -8,7 +8,7 @@ namespace NzbDrone.Core.Indexers.TorrentRss public class TorrentRssIndexerRequestGenerator : IIndexerRequestGenerator { public TorrentRssIndexerSettings Settings { get; set; } - + public virtual IndexerPageableRequestChain GetRecentRequests() { var pageableRequests = new IndexerPageableRequestChain(); @@ -33,6 +33,11 @@ namespace NzbDrone.Core.Indexers.TorrentRss return new IndexerPageableRequestChain(); } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } + public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) { return new IndexerPageableRequestChain(); diff --git a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerSettings.cs b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerSettings.cs index ef2b74f9a..93e9bbebd 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 @@ -10,10 +9,12 @@ namespace NzbDrone.Core.Indexers.TorrentRss public TorrentRssIndexerSettingsValidator() { RuleFor(c => c.BaseUrl).ValidRootUrl(); + + RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator()); } } - public class TorrentRssIndexerSettings : IProviderConfig + public class TorrentRssIndexerSettings : ITorrentIndexerSettings { private static readonly TorrentRssIndexerSettingsValidator validator = new TorrentRssIndexerSettingsValidator(); @@ -21,6 +22,7 @@ namespace NzbDrone.Core.Indexers.TorrentRss { BaseUrl = string.Empty; AllowZeroSize = false; + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; } [FieldDefinition(0, Label = "Full RSS Feed URL")] @@ -32,9 +34,15 @@ 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; } + + [FieldDefinition(4)] + public SeedCriteriaSettings SeedCriteria { get; } = new SeedCriteriaSettings(); + 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/TorrentleechRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechRequestGenerator.cs index ebfa73788..d50e25011 100644 --- a/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechRequestGenerator.cs @@ -7,7 +7,7 @@ namespace NzbDrone.Core.Indexers.Torrentleech public class TorrentleechRequestGenerator : IIndexerRequestGenerator { public TorrentleechSettings Settings { get; set; } - + public virtual IndexerPageableRequestChain GetRecentRequests() { var pageableRequests = new IndexerPageableRequestChain(); @@ -32,6 +32,11 @@ namespace NzbDrone.Core.Indexers.Torrentleech return new IndexerPageableRequestChain(); } + public virtual IndexerPageableRequestChain GetSearchRequests(DailySeasonSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } + public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) { return new IndexerPageableRequestChain(); diff --git a/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs index 957bfc3ed..0a0984647 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 @@ -11,16 +10,19 @@ namespace NzbDrone.Core.Indexers.Torrentleech { RuleFor(c => c.BaseUrl).ValidRootUrl(); RuleFor(c => c.ApiKey).NotEmpty(); + + RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator()); } } - 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 +31,15 @@ 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; } + + [FieldDefinition(3)] + public SeedCriteriaSettings SeedCriteria { get; } = new SeedCriteriaSettings(); + 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..a671b4093 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://feed.animetosho.org", apiPath: @"/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..0e0437108 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,34 @@ 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()); + + RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator()); } } - 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; } + + [FieldDefinition(7)] + public SeedCriteriaSettings SeedCriteria { get; } = new SeedCriteriaSettings(); + 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..d4f3b97a9 100644 --- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -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) { @@ -95,13 +95,15 @@ namespace NzbDrone.Core.MediaFiles { _logger.Debug("Series folder doesn't exist: {0}", series.Path); } + CleanMediaFiles(series, new List()); CompletedScanning(series); + return; } 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); @@ -113,6 +115,7 @@ namespace NzbDrone.Core.MediaFiles _logger.Trace("Import decisions complete for: {0} [{1}]", series, decisionsStopwatch.Elapsed); _importApprovedEpisodes.Import(decisions, false); + RemoveEmptySeriesFolder(series.Path); CompletedScanning(series); } @@ -135,7 +138,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 +153,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 +161,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 +187,22 @@ namespace NzbDrone.Core.MediaFiles _logger.Warn(ex, "Unable to apply permissions to: " + path); _logger.Debug(ex, ex.Message); } - } + } + + private void RemoveEmptySeriesFolder(string path) + { + if (_configService.DeleteEmptyFolders) + { + if (_diskProvider.GetFiles(path, SearchOption.AllDirectories).Empty()) + { + _diskProvider.DeleteFolder(path, true); + } + else + { + _diskProvider.RemoveEmptySubfolders(path); + } + } + } public void Handle(SeriesUpdatedEvent message) { @@ -210,4 +228,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..7f77c1286 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; @@ -158,15 +156,8 @@ namespace NzbDrone.Core.MediaFiles return new List(); } - var cleanedUpName = GetCleanedUpFolderName(directoryInfo.Name); var folderInfo = Parser.Parser.ParseTitle(directoryInfo.Name); - - if (folderInfo != null) - { - _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 +173,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 +232,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/EpisodeFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs index f2a0b9be6..5ffed5567 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -12,6 +12,7 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tv; +using NzbDrone.Core.MediaFiles.EpisodeImport; namespace NzbDrone.Core.MediaFiles { @@ -157,7 +158,7 @@ namespace NzbDrone.Core.MediaFiles if (!_diskProvider.FolderExists(rootFolder)) { - throw new DirectoryNotFoundException(string.Format("Root folder '{0}' was not found.", rootFolder)); + throw new RootFolderNotFoundException(string.Format("Root folder '{0}' was not found.", rootFolder)); } var changed = false; diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/AggregationFailedException.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/AggregationFailedException.cs new file mode 100644 index 000000000..948df4580 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/AggregationFailedException.cs @@ -0,0 +1,24 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation +{ + public class AugmentingFailedException : NzbDroneException + { + public AugmentingFailedException(string message, params object[] args) : base(message, args) + { + } + + public AugmentingFailedException(string message) : base(message) + { + } + + public AugmentingFailedException(string message, Exception innerException, params object[] args) : base(message, innerException, args) + { + } + + public AugmentingFailedException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/AggregationService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/AggregationService.cs new file mode 100644 index 000000000..b66078ce6 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/AggregationService.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.IO; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators; +using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation +{ + public interface IAugmentingService + { + LocalEpisode Augment(LocalEpisode localEpisode, bool otherFiles); + } + + public class AugmentingService : IAugmentingService + { + private readonly IEnumerable _augmenters; + private readonly IDiskProvider _diskProvider; + private readonly IVideoFileInfoReader _videoFileInfoReader; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public AugmentingService(IEnumerable augmenters, + IDiskProvider diskProvider, + IVideoFileInfoReader videoFileInfoReader, + IConfigService configService, + Logger logger) + { + _augmenters = augmenters; + _diskProvider = diskProvider; + _videoFileInfoReader = videoFileInfoReader; + _configService = configService; + _logger = logger; + } + + public LocalEpisode Augment(LocalEpisode localEpisode, bool otherFiles) + { + if (localEpisode.DownloadClientEpisodeInfo == null && + localEpisode.FolderEpisodeInfo == null && + localEpisode.FileEpisodeInfo == null) + { + if (MediaFileExtensions.Extensions.Contains(Path.GetExtension(localEpisode.Path))) + { + throw new AugmentingFailedException("Unable to parse episode info from path: {0}", localEpisode.Path); + } + } + + localEpisode.Size = _diskProvider.GetFileSize(localEpisode.Path); + + if (!localEpisode.ExistingFile || _configService.EnableMediaInfo) + { + localEpisode.MediaInfo = _videoFileInfoReader.GetMediaInfo(localEpisode.Path); + } + + foreach (var augmenter in _augmenters) + { + try + { + augmenter.Aggregate(localEpisode, otherFiles); + } + catch (Exception ex) + { + _logger.Warn(ex, ex.Message); + } + } + + return localEpisode; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateEpisodes.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateEpisodes.cs new file mode 100644 index 000000000..cb624d452 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateEpisodes.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.IO; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators +{ + public class AggregateEpisodes : IAggregateLocalEpisode + { + private readonly IParsingService _parsingService; + + public AggregateEpisodes(IParsingService parsingService) + { + _parsingService = parsingService; + } + + public LocalEpisode Aggregate(LocalEpisode localEpisode, bool otherFiles) + { + localEpisode.Episodes = GetEpisodes(localEpisode, otherFiles); + + return localEpisode; + } + + private ParsedEpisodeInfo GetBestEpisodeInfo(LocalEpisode localEpisode, bool otherFiles) + { + var parsedEpisodeInfo = localEpisode.FileEpisodeInfo; + var downloadClientEpisodeInfo = localEpisode.DownloadClientEpisodeInfo; + var folderEpisodeInfo = localEpisode.FolderEpisodeInfo; + + if (!otherFiles && !SceneChecker.IsSceneTitle(Path.GetFileNameWithoutExtension(localEpisode.Path))) + { + if (downloadClientEpisodeInfo != null && !downloadClientEpisodeInfo.FullSeason) + { + parsedEpisodeInfo = localEpisode.DownloadClientEpisodeInfo; + } + else if (folderEpisodeInfo != null && !folderEpisodeInfo.FullSeason) + { + parsedEpisodeInfo = localEpisode.FolderEpisodeInfo; + } + } + + if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode) + { + var title = Path.GetFileNameWithoutExtension(localEpisode.Path); + var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(parsedEpisodeInfo, title, localEpisode.Series); + + return specialEpisodeInfo; + } + + return parsedEpisodeInfo; + } + + private List GetEpisodes(LocalEpisode localEpisode, bool otherFiles) + { + var bestEpisodeInfoForEpisodes = GetBestEpisodeInfo(localEpisode, otherFiles); + var isMediaFile = MediaFileExtensions.Extensions.Contains(Path.GetExtension(localEpisode.Path)); + + if (bestEpisodeInfoForEpisodes == null) + { + return new List(); + } + + if (ValidateParsedEpisodeInfo.ValidateForSeriesType(bestEpisodeInfoForEpisodes, localEpisode.Series, isMediaFile)) + { + return _parsingService.GetEpisodes(bestEpisodeInfoForEpisodes, localEpisode.Series, localEpisode.SceneSource); + } + + return new List(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateQuality.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateQuality.cs new file mode 100644 index 000000000..c4cf27b5e --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateQuality.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators.Augmenters.Quality; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators +{ + public class AggregateQuality : IAggregateLocalEpisode + { + private readonly IEnumerable _augmentQualities; + private readonly Logger _logger; + + public AggregateQuality(IEnumerable augmentQualities, + Logger logger) + { + _augmentQualities = augmentQualities; + _logger = logger; + } + + public LocalEpisode Aggregate(LocalEpisode localEpisode, bool otherFiles) + { + var augmentedQualities = _augmentQualities.Select(a => a.AugmentQuality(localEpisode)) + .Where(a => a != null) + .OrderBy(a => a.SourceConfidence); + + var source = QualitySource.Unknown; + var sourceConfidence = Confidence.Default; + var resolution = 0; + var resolutionConfidence = Confidence.Default; + var revison = new Revision(); + + foreach (var augmentedQuality in augmentedQualities) + { + if (augmentedQuality.Source > source || + augmentedQuality.SourceConfidence > sourceConfidence && augmentedQuality.Source != QualitySource.Unknown) + { + source = augmentedQuality.Source; + sourceConfidence = augmentedQuality.SourceConfidence; + } + + if (augmentedQuality.Resolution > resolution || + augmentedQuality.ResolutionConfidence > resolutionConfidence && augmentedQuality.Resolution > 0) + { + resolution = augmentedQuality.Resolution; + resolutionConfidence = augmentedQuality.ResolutionConfidence; + } + + if (augmentedQuality.Revision != null && augmentedQuality.Revision > revison) + { + revison = augmentedQuality.Revision; + } + } + + var quality = new QualityModel(Quality.FindBySourceAndResolution(source, resolution), revison); + + if (resolutionConfidence == Confidence.MediaInfo) + { + quality.QualityDetectionSource = QualityDetectionSource.MediaInfo; + } + else if (sourceConfidence == Confidence.Fallback || resolutionConfidence == Confidence.Fallback) + { + quality.QualityDetectionSource = QualityDetectionSource.Extension; + } + else + { + quality.QualityDetectionSource = QualityDetectionSource.Name; + } + + _logger.Debug("Using quality: {0}", quality); + + localEpisode.Quality = quality; + + return localEpisode; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateReleaseGroup.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateReleaseGroup.cs new file mode 100644 index 000000000..a919320b8 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateReleaseGroup.cs @@ -0,0 +1,27 @@ +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators +{ + public class AggregateReleaseGroup : IAggregateLocalEpisode + { + public LocalEpisode Aggregate(LocalEpisode localEpisode, bool otherFiles) + { + var releaseGroup = localEpisode.DownloadClientEpisodeInfo?.ReleaseGroup; + + if (releaseGroup.IsNullOrWhiteSpace()) + { + releaseGroup = localEpisode.FolderEpisodeInfo?.ReleaseGroup; + } + + if (releaseGroup.IsNullOrWhiteSpace()) + { + releaseGroup = localEpisode.FileEpisodeInfo?.ReleaseGroup; + } + + localEpisode.ReleaseGroup = releaseGroup; + + return localEpisode; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromDownloadClientItem.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromDownloadClientItem.cs new file mode 100644 index 000000000..13da77969 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromDownloadClientItem.cs @@ -0,0 +1,23 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators.Augmenters.Quality +{ + public class AugmentQualityFromDownloadClientItem : IAugmentQuality + { + public AugmentQualityResult AugmentQuality(LocalEpisode localEpisode) + { + var quality = localEpisode.DownloadClientEpisodeInfo?.Quality; + + if (quality == null) + { + return null; + } + + return new AugmentQualityResult(quality.Quality.Source, + Confidence.Tag, + quality.Quality.Resolution, + Confidence.Tag, + quality.Revision); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromFileName.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromFileName.cs new file mode 100644 index 000000000..53475b5bb --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromFileName.cs @@ -0,0 +1,28 @@ +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators.Augmenters.Quality +{ + public class AugmentQualityFromFileName : IAugmentQuality + { + public AugmentQualityResult AugmentQuality(LocalEpisode localEpisode) + { + var quality = localEpisode.FileEpisodeInfo?.Quality; + + if (quality == null) + { + return null; + } + + var confidence = quality.QualityDetectionSource == QualityDetectionSource.Extension + ? Confidence.Fallback + : Confidence.Tag; + + return new AugmentQualityResult(quality.Quality.Source, + confidence, + quality.Quality.Resolution, + confidence, + quality.Revision); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromFolder.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromFolder.cs new file mode 100644 index 000000000..e59f3dbdc --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromFolder.cs @@ -0,0 +1,23 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators.Augmenters.Quality +{ + public class AugmentQualityFromFolder : IAugmentQuality + { + public AugmentQualityResult AugmentQuality(LocalEpisode localEpisode) + { + var quality = localEpisode.FolderEpisodeInfo?.Quality; + + if (quality == null) + { + return null; + } + + return new AugmentQualityResult(quality.Quality.Source, + Confidence.Tag, + quality.Quality.Resolution, + Confidence.Tag, + quality.Revision); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromMediaInfo.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromMediaInfo.cs new file mode 100644 index 000000000..958e226f8 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromMediaInfo.cs @@ -0,0 +1,39 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators.Augmenters.Quality +{ + public class AugmentQualityFromMediaInfo : IAugmentQuality + { + public AugmentQualityResult AugmentQuality(LocalEpisode localEpisode) + { + if (localEpisode.MediaInfo == null) + { + return null; + } + + var width = localEpisode.MediaInfo.Width; + + if (width >= 3200) + { + return AugmentQualityResult.ResolutionOnly(2160, Confidence.MediaInfo); + } + + if (width >= 1800) + { + return AugmentQualityResult.ResolutionOnly(1080, Confidence.MediaInfo); + } + + if (width >= 1200) + { + return AugmentQualityResult.ResolutionOnly(720, Confidence.MediaInfo); + } + + if (width > 0) + { + return AugmentQualityResult.ResolutionOnly(480, Confidence.MediaInfo); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityResult.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityResult.cs new file mode 100644 index 000000000..885d90cca --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityResult.cs @@ -0,0 +1,36 @@ +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators.Augmenters.Quality +{ + public class AugmentQualityResult + { + public QualitySource Source { get; set; } + public Confidence SourceConfidence { get; set; } + public int Resolution { get; set; } + public Confidence ResolutionConfidence { get; set; } + public Revision Revision { get; set; } + + public AugmentQualityResult(QualitySource source, + Confidence sourceConfidence, + int resolution, + Confidence resolutionConfidence, + Revision revision) + { + Source = source; + SourceConfidence = sourceConfidence; + Resolution = resolution; + ResolutionConfidence = resolutionConfidence; + Revision = revision; + } + + public static AugmentQualityResult SourceOnly(QualitySource source, Confidence sourceConfidence) + { + return new AugmentQualityResult(source, sourceConfidence, 0, Confidence.Default, null); + } + + public static AugmentQualityResult ResolutionOnly(int resolution, Confidence resolutionConfidence) + { + return new AugmentQualityResult(QualitySource.Unknown, Confidence.Default, resolution, resolutionConfidence, null); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/Confidence.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/Confidence.cs new file mode 100644 index 000000000..2bb3f281f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/Confidence.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators.Augmenters.Quality +{ + public enum Confidence + { + Fallback, + Default, + Tag, + MediaInfo + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/IAugmentQuality.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/IAugmentQuality.cs new file mode 100644 index 000000000..6bf464321 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/IAugmentQuality.cs @@ -0,0 +1,9 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators.Augmenters.Quality +{ + public interface IAugmentQuality + { + AugmentQualityResult AugmentQuality(LocalEpisode localEpisode); + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/IAggregateLocalEpisode.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/IAggregateLocalEpisode.cs new file mode 100644 index 000000000..452cac2a8 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/IAggregateLocalEpisode.cs @@ -0,0 +1,9 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators +{ + public interface IAggregateLocalEpisode + { + LocalEpisode Aggregate(LocalEpisode localEpisode, bool otherFiles); + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs index 27492d56a..b3e45c183 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,50 @@ 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 + // TODO: Use MediaInfo from the import process, no need to re-process the file again here + 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..c894daaab 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -12,7 +12,7 @@ using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.Download; using NzbDrone.Core.Extras; - +using NzbDrone.Common.Exceptions; namespace NzbDrone.Core.MediaFiles.EpisodeImport { @@ -83,14 +83,14 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport episodeFile.MediaInfo = localEpisode.MediaInfo; episodeFile.SeasonNumber = localEpisode.SeasonNumber; episodeFile.Episodes = localEpisode.Episodes; - episodeFile.ReleaseGroup = localEpisode.ParsedEpisodeInfo.ReleaseGroup; + episodeFile.ReleaseGroup = localEpisode.ReleaseGroup; bool copyOnly; switch (importMode) { default: case ImportMode.Auto: - copyOnly = downloadClientItem != null && downloadClientItem.IsReadOnly; + copyOnly = downloadClientItem != null && !downloadClientItem.CanMoveFiles; break; case ImportMode.Move: copyOnly = false; @@ -117,22 +117,22 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport if (newDownload) { - _extraService.ImportExtraFiles(localEpisode, episodeFile, copyOnly); + _extraService.ImportEpisode(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)); - } + _eventAggregator.PublishEvent(new EpisodeImportedEvent(localEpisode, episodeFile, oldFiles, newDownload, downloadClientItem)); + } + catch (RootFolderNotFoundException e) + { + _logger.Warn(e, "Couldn't import episode " + localEpisode); + _eventAggregator.PublishEvent(new EpisodeImportFailedEvent(e, localEpisode, newDownload, downloadClientItem)); - if (newDownload) - { - _eventAggregator.PublishEvent(new EpisodeDownloadedEvent(localEpisode, episodeFile, oldFiles)); - } + importResults.Add(new ImportResult(importDecision, "Failed to import episode, Root folder missing.")); + } + catch (DestinationAlreadyExistsException e) + { + _logger.Warn(e, "Couldn't import episode " + localEpisode); + importResults.Add(new ImportResult(importDecision, "Failed to import episode, Destination already exists.")); } catch (Exception e) { diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs index 764e1b88f..2b3291006 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -6,133 +6,156 @@ using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser; +using NzbDrone.Core.Download; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; using NzbDrone.Core.Tv; -using NzbDrone.Core.MediaFiles.MediaInfo; - 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 { private readonly IEnumerable _specifications; - private readonly IParsingService _parsingService; private readonly IMediaFileService _mediaFileService; + private readonly IAugmentingService _augmentingService; private readonly IDiskProvider _diskProvider; - private readonly IVideoFileInfoReader _videoFileInfoReader; private readonly IDetectSample _detectSample; private readonly Logger _logger; public ImportDecisionMaker(IEnumerable specifications, - IParsingService parsingService, IMediaFileService mediaFileService, + IAugmentingService augmentingService, IDiskProvider diskProvider, - IVideoFileInfoReader videoFileInfoReader, IDetectSample detectSample, Logger logger) { _specifications = specifications; - _parsingService = parsingService; _mediaFileService = mediaFileService; + _augmentingService = augmentingService; _diskProvider = diskProvider; - _videoFileInfoReader = videoFileInfoReader; _detectSample = detectSample; _logger = logger; } 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); _logger.Debug("Analyzing {0}/{1} files.", newFiles.Count, videoFiles.Count()); - var shouldUseFolderName = ShouldUseFolderName(videoFiles, series, folderInfo); + ParsedEpisodeInfo downloadClientItemInfo = null; + + if (downloadClientItem != null) + { + downloadClientItemInfo = Parser.Parser.ParseTitle(downloadClientItem.Title); + } + + var nonSampleVideoFileCount = GetNonSampleVideoFileCount(newFiles, series, downloadClientItemInfo, folderInfo); + var decisions = new List(); foreach (var file in newFiles) { - decisions.AddIfNotNull(GetDecision(file, series, folderInfo, sceneSource, shouldUseFolderName)); + var localEpisode = new LocalEpisode + { + Series = series, + DownloadClientEpisodeInfo = downloadClientItemInfo, + FolderEpisodeInfo = folderInfo, + Path = file, + SceneSource = sceneSource, + ExistingFile = series.Path.IsParentPath(file) + }; + + decisions.AddIfNotNull(GetDecision(localEpisode, downloadClientItem, nonSampleVideoFileCount > 1)); } return decisions; } - private ImportDecision GetDecision(string file, Series series, ParsedEpisodeInfo folderInfo, bool sceneSource, bool shouldUseFolderName) + private ImportDecision GetDecision(LocalEpisode localEpisode, DownloadClientItem downloadClientItem, bool otherFiles) { ImportDecision decision = null; + var fileEpisodeInfo = Parser.Parser.ParsePath(localEpisode.Path); + + localEpisode.FileEpisodeInfo = fileEpisodeInfo; + localEpisode.Size = _diskProvider.GetFileSize(localEpisode.Path); + try { - var localEpisode = _parsingService.GetLocalEpisode(file, series, shouldUseFolderName ? folderInfo : null, sceneSource); + _augmentingService.Augment(localEpisode, otherFiles); - if (localEpisode != null) + if (localEpisode.Episodes.Empty()) { - localEpisode.Quality = GetQuality(folderInfo, localEpisode.Quality, series); - localEpisode.Size = _diskProvider.GetFileSize(file); - - _logger.Debug("Size: {0}", localEpisode.Size); - - //TODO: make it so media info doesn't ruin the import process of a new series - if (sceneSource) + if (IsPartialSeason(localEpisode)) { - localEpisode.MediaInfo = _videoFileInfoReader.GetMediaInfo(file); + decision = new ImportDecision(localEpisode, new Rejection("Partial season packs are not supported")); } - - if (localEpisode.Episodes.Empty()) + else if (IsSeasonExtra(localEpisode)) { - decision = new ImportDecision(localEpisode, new Rejection("Invalid season or episode")); + decision = new ImportDecision(localEpisode, new Rejection("Extras are not supported")); } else { - decision = GetDecision(localEpisode); + decision = new ImportDecision(localEpisode, new Rejection("Invalid season or episode")); } } - else { - localEpisode = new LocalEpisode(); - localEpisode.Path = file; - - decision = new ImportDecision(localEpisode, new Rejection("Unable to parse file")); + decision = GetDecision(localEpisode, downloadClientItem); } } - catch (Exception e) + catch (AugmentingFailedException) { - _logger.Error(e, "Couldn't import file. {0}", file); + decision = new ImportDecision(localEpisode, new Rejection("Unable to parse file")); + } + catch (Exception ex) + { + _logger.Error(ex, "Couldn't import file. {0}", localEpisode.Path); - var localEpisode = new LocalEpisode { Path = file }; decision = new ImportDecision(localEpisode, new Rejection("Unexpected error processing file")); } + if (decision == null) + { + _logger.Error("Unable to make a decision on {0}", localEpisode.Path); + } + else if (decision.Rejections.Any()) + { + _logger.Debug("File rejected for the following reasons: {0}", string.Join(", ", decision.Rejections)); + } + else + { + _logger.Debug("File accepted"); + } + 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) { @@ -150,67 +173,66 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return null; } - private bool ShouldUseFolderName(List videoFiles, Series series, ParsedEpisodeInfo folderInfo) + private int GetNonSampleVideoFileCount(List videoFiles, Series series, ParsedEpisodeInfo downloadClientItemInfo, ParsedEpisodeInfo folderInfo) { - if (folderInfo == null) - { - return false; - } - - if (folderInfo.FullSeason) - { - return false; - } + var isPossibleSpecialEpisode = downloadClientItemInfo?.IsPossibleSpecialEpisode ?? false; + // If we might already have a special, don't try to get it from the folder info. + isPossibleSpecialEpisode = isPossibleSpecialEpisode || (folderInfo?.IsPossibleSpecialEpisode ?? false); 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, isPossibleSpecialEpisode); - if (sample) - { - return false; - } - - if (SceneChecker.IsSceneTitle(Path.GetFileName(file))) + if (sample == DetectSampleResult.Sample) { return false; } return true; - }) == 1; + }); } - private QualityModel GetQuality(ParsedEpisodeInfo folderInfo, QualityModel fileQuality, Series series) + private bool IsPartialSeason(LocalEpisode localEpisode) { - if (UseFolderQuality(folderInfo, fileQuality, series)) - { - _logger.Debug("Using quality from folder: {0}", folderInfo.Quality); - return folderInfo.Quality; - } + var downloadClientEpisodeInfo = localEpisode.DownloadClientEpisodeInfo; + var folderEpisodeInfo = localEpisode.FolderEpisodeInfo; + var fileEpisodeInfo = localEpisode.FileEpisodeInfo; - return fileQuality; - } - - private bool UseFolderQuality(ParsedEpisodeInfo folderInfo, QualityModel fileQuality, Series series) - { - if (folderInfo == null) - { - return false; - } - - if (folderInfo.Quality.Quality == Quality.Unknown) - { - return false; - } - - if (fileQuality.QualitySource == QualitySource.Extension) + if (downloadClientEpisodeInfo != null && downloadClientEpisodeInfo.IsPartialSeason) { return true; } - if (new QualityModelComparer(series.Profile).Compare(folderInfo.Quality, fileQuality) > 0) + if (folderEpisodeInfo != null && folderEpisodeInfo.IsPartialSeason) + { + return true; + } + + if (fileEpisodeInfo != null && fileEpisodeInfo.IsPartialSeason) + { + return true; + } + + return false; + } + + private bool IsSeasonExtra(LocalEpisode localEpisode) + { + var downloadClientEpisodeInfo = localEpisode.DownloadClientEpisodeInfo; + var folderEpisodeInfo = localEpisode.FolderEpisodeInfo; + var fileEpisodeInfo = localEpisode.FileEpisodeInfo; + + if (downloadClientEpisodeInfo != null && downloadClientEpisodeInfo.IsSeasonExtra) + { + return true; + } + + if (folderEpisodeInfo != null && folderEpisodeInfo.IsSeasonExtra) + { + return true; + } + + if (fileEpisodeInfo != null && fileEpisodeInfo.IsSeasonExtra) { return true; } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs index 4c9fecc7c..f0d42b21d 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs @@ -6,6 +6,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual public class ManualImportFile { public string Path { get; set; } + public string FolderName { get; set; } public int SeriesId { get; set; } public List EpisodeIds { get; set; } public QualityModel Quality { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs index bd3954816..6f055d7d2 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs @@ -9,6 +9,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual { public string Path { get; set; } public string RelativePath { get; set; } + public string FolderName { get; set; } public string Name { get; set; } public long Size { get; set; } public Series Series { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs index d85a2e119..40ae716fe 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -9,6 +9,7 @@ using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation; using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; @@ -33,6 +34,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual private readonly IEpisodeService _episodeService; private readonly IVideoFileInfoReader _videoFileInfoReader; private readonly IImportApprovedEpisodes _importApprovedEpisodes; + private readonly IAugmentingService _augmentingService; private readonly ITrackedDownloadService _trackedDownloadService; private readonly IDownloadedEpisodesImportService _downloadedEpisodesImportService; private readonly IEventAggregator _eventAggregator; @@ -45,6 +47,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual ISeriesService seriesService, IEpisodeService episodeService, IVideoFileInfoReader videoFileInfoReader, + IAugmentingService augmentingService, IImportApprovedEpisodes importApprovedEpisodes, ITrackedDownloadService trackedDownloadService, IDownloadedEpisodesImportService downloadedEpisodesImportService, @@ -58,6 +61,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual _seriesService = seriesService; _episodeService = episodeService; _videoFileInfoReader = videoFileInfoReader; + _augmentingService = augmentingService; _importApprovedEpisodes = importApprovedEpisodes; _trackedDownloadService = trackedDownloadService; _downloadedEpisodesImportService = downloadedEpisodesImportService; @@ -86,46 +90,52 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual return new List(); } - return new List { ProcessFile(path, downloadId) }; + var rootFolder = Path.GetDirectoryName(path); + return new List { ProcessFile(rootFolder, rootFolder, path, downloadId) }; } - return ProcessFolder(path, downloadId); + return ProcessFolder(path, path, downloadId); } - private List ProcessFolder(string folder, string downloadId) + private List ProcessFolder(string rootFolder, string baseFolder, string downloadId) { - var directoryInfo = new DirectoryInfo(folder); + DownloadClientItem downloadClientItem = null; + var directoryInfo = new DirectoryInfo(baseFolder); 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(baseFolder, _diskScanService.GetVideoFiles(baseFolder, false)); + var subfolders = _diskScanService.FilterFiles(baseFolder, _diskProvider.GetDirectories(baseFolder)); - return files.Select(file => ProcessFile(file, downloadId, folder)).Where(i => i != null).ToList(); + var processedFiles = files.Select(file => ProcessFile(rootFolder, baseFolder, file, downloadId)); + var processedFolders = subfolders.SelectMany(subfolder => ProcessFolder(rootFolder, subfolder, downloadId)); + + return processedFiles.Concat(processedFolders).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 seriesFiles = _diskScanService.GetVideoFiles(baseFolder).ToList(); + var decisions = _importDecisionMaker.GetImportDecisions(seriesFiles, series, downloadClientItem, folderInfo, SceneSource(series, baseFolder)); - return decisions.Select(decision => MapItem(decision, folder, downloadId)).ToList(); + return decisions.Select(decision => MapItem(decision, rootFolder, downloadId, directoryInfo.Name)).ToList(); } - private ManualImportItem ProcessFile(string file, string downloadId, string folder = null) + private ManualImportItem ProcessFile(string rootFolder, string baseFolder, string file, string downloadId) { - if (folder.IsNullOrWhiteSpace()) - { - folder = new FileInfo(file).Directory.FullName; - } - - var relativeFile = folder.GetRelativePath(file); - + DownloadClientItem downloadClientItem = null; + var relativeFile = baseFolder.GetRelativePath(file); var series = _parsingService.GetSeries(relativeFile.Split('\\', '/')[0]); if (series == null) @@ -133,10 +143,25 @@ 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) + { + var relativeParseInfo = Parser.Parser.ParsePath(relativeFile); + + if (relativeParseInfo != null) + { + series = _seriesService.FindByTitle(relativeParseInfo.SeriesTitle); + } } if (series == null) @@ -146,13 +171,25 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual localEpisode.Quality = QualityParser.ParseQuality(file); localEpisode.Size = _diskProvider.GetFileSize(file); - return MapItem(new ImportDecision(localEpisode, new Rejection("Unknown Series")), folder, downloadId); + return MapItem(new ImportDecision(localEpisode, new Rejection("Unknown Series")), rootFolder, downloadId, null); } var importDecisions = _importDecisionMaker.GetImportDecisions(new List {file}, - series, null, SceneSource(series, folder)); + series, downloadClientItem, null, SceneSource(series, baseFolder)); - return importDecisions.Any() ? MapItem(importDecisions.First(), folder, downloadId) : null; + if (importDecisions.Any()) + { + return MapItem(importDecisions.First(), rootFolder, downloadId, null); + } + + return new ManualImportItem + { + DownloadId = downloadId, + Path = file, + RelativePath = rootFolder.GetRelativePath(file), + Name = Path.GetFileNameWithoutExtension(file), + Rejections = new List() + }; } private bool SceneSource(Series series, string folder) @@ -160,12 +197,13 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual return !(series.Path.PathEquals(folder) || series.Path.IsParentPath(folder)); } - private ManualImportItem MapItem(ImportDecision decision, string folder, string downloadId) + private ManualImportItem MapItem(ImportDecision decision, string rootFolder, string downloadId, string folderName) { var item = new ManualImportItem(); item.Path = decision.LocalEpisode.Path; - item.RelativePath = folder.GetRelativePath(decision.LocalEpisode.Path); + item.FolderName = folderName; + item.RelativePath = rootFolder.GetRelativePath(decision.LocalEpisode.Path); item.Name = Path.GetFileNameWithoutExtension(decision.LocalEpisode.Path); item.DownloadId = downloadId; @@ -174,10 +212,23 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual item.Series = decision.LocalEpisode.Series; } - if (decision.LocalEpisode.Episodes.Any()) + if (decision.LocalEpisode.Episodes.Any() && decision.LocalEpisode.Episodes.Select(c => c.SeasonNumber).Distinct().Count() == 1) { - item.SeasonNumber = decision.LocalEpisode.SeasonNumber; - item.Episodes = decision.LocalEpisode.Episodes; + var seasons = decision.LocalEpisode.Episodes.Select(c => c.SeasonNumber).Distinct().ToList(); + + if (seasons.Empty()) + { + _logger.Warn("Expected one season, but found none for: {0}", decision.LocalEpisode.Path); + } + else if (seasons.Count > 1) + { + _logger.Warn("Expected one season, but found {0} ({1}) for: {2}", seasons.Count, string.Join(", ", seasons), decision.LocalEpisode.Path); + } + else + { + item.SeasonNumber = decision.LocalEpisode.SeasonNumber; + item.Episodes = decision.LocalEpisode.Episodes; + } } item.Quality = decision.LocalEpisode.Quality; @@ -201,34 +252,52 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual var file = message.Files[i]; var series = _seriesService.GetSeries(file.SeriesId); var episodes = _episodeService.GetEpisodes(file.EpisodeIds); - var parsedEpisodeInfo = Parser.Parser.ParsePath(file.Path) ?? new ParsedEpisodeInfo(); - var mediaInfo = _videoFileInfoReader.GetMediaInfo(file.Path); + var fileEpisodeInfo = Parser.Parser.ParsePath(file.Path) ?? new ParsedEpisodeInfo(); var existingFile = series.Path.IsParentPath(file.Path); + TrackedDownload trackedDownload = null; var localEpisode = new LocalEpisode { ExistingFile = false, Episodes = episodes, - MediaInfo = mediaInfo, - ParsedEpisodeInfo = parsedEpisodeInfo, + FileEpisodeInfo = fileEpisodeInfo, Path = file.Path, Quality = file.Quality, Series = series, Size = 0 }; + if (file.DownloadId.IsNotNullOrWhiteSpace()) + { + trackedDownload = _trackedDownloadService.Find(file.DownloadId); + if (trackedDownload != null) + { + localEpisode.DownloadClientEpisodeInfo = trackedDownload.RemoteEpisode.ParsedEpisodeInfo; + } + } + + if (file.FolderName.IsNotNullOrWhiteSpace()) + { + localEpisode.FolderEpisodeInfo = Parser.Parser.ParseTitle(file.FolderName); + } + + localEpisode = _augmentingService.Augment(localEpisode, false); + + // Apply the user-chosen values. + localEpisode.Series = series; + localEpisode.Episodes = episodes; + localEpisode.Quality = file.Quality; + //TODO: Cleanup non-tracked downloads var importDecision = new ImportDecision(localEpisode); - if (file.DownloadId.IsNullOrWhiteSpace()) + if (trackedDownload == null) { imported.AddRange(_importApprovedEpisodes.Import(new List { importDecision }, !existingFile, null, message.ImportMode)); } - else { - var trackedDownload = _trackedDownloadService.Find(file.DownloadId); var importResult = _importApprovedEpisodes.Import(new List { importDecision }, true, trackedDownload.DownloadItem, message.ImportMode).First(); imported.Add(importResult); @@ -251,7 +320,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/RootFolderNotFoundException.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/RootFolderNotFoundException.cs new file mode 100644 index 000000000..afa918a77 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/RootFolderNotFoundException.cs @@ -0,0 +1,25 @@ +using System; +using System.IO; +using System.Runtime.Serialization; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport +{ + public class RootFolderNotFoundException : DirectoryNotFoundException + { + public RootFolderNotFoundException() + { + } + + public RootFolderNotFoundException(string message) : base(message) + { + } + + public RootFolderNotFoundException(string message, Exception innerException) : base(message, innerException) + { + } + + protected RootFolderNotFoundException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/EpisodeTitleSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/EpisodeTitleSpecification.cs new file mode 100644 index 000000000..2ec04f4fd --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/EpisodeTitleSpecification.cs @@ -0,0 +1,58 @@ +using System; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications +{ + public class EpisodeTitleSpecification : IImportDecisionEngineSpecification + { + private readonly IBuildFileNames _buildFileNames; + private readonly Logger _logger; + + public EpisodeTitleSpecification(IBuildFileNames buildFileNames, Logger logger) + { + _buildFileNames = buildFileNames; + _logger = logger; + } + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) + { + if (!_buildFileNames.RequiresEpisodeTitle(localEpisode.Series, localEpisode.Episodes)) + { + _logger.Debug("File name format does not require episode title, skipping check"); + return Decision.Accept(); + } + + foreach (var episode in localEpisode.Episodes) + { + var airDateUtc = episode.AirDateUtc; + var title = episode.Title; + + if (airDateUtc.HasValue && airDateUtc.Value.Before(DateTime.UtcNow.AddDays(-1))) + { + _logger.Debug("Episode aired more than 1 day ago"); + continue; + } + + if (title.IsNullOrWhiteSpace()) + { + _logger.Debug("Episode does not have a title and recently aired"); + + return Decision.Reject("Episode does not have a title and recently aired"); + } + + if (title.Equals("TBA")) + { + _logger.Debug("Episode has a TBA title and recently aired"); + + return Decision.Reject("Episode has a TBA title and recently aired"); + } + } + + return Decision.Accept(); + } + } +} 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..37ecbbf40 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 NLog; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications @@ -13,9 +14,9 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { - if (localEpisode.ParsedEpisodeInfo.FullSeason) + if (localEpisode.FileEpisodeInfo.FullSeason) { _logger.Debug("Single episode file detected as containing all episodes in the season"); return Decision.Reject("Single episode file contains all episodes in seasons"); 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..2f69ea95c 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs @@ -1,7 +1,8 @@ -using System.IO; using System.Linq; using NLog; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications @@ -9,27 +10,28 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications public class MatchesFolderSpecification : IImportDecisionEngineSpecification { private readonly Logger _logger; + private readonly IParsingService _parsingService; - public MatchesFolderSpecification(Logger logger) + public MatchesFolderSpecification(IParsingService parsingService, Logger logger) { _logger = logger; + _parsingService = parsingService; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { if (localEpisode.ExistingFile) { return Decision.Accept(); } - var dirInfo = new FileInfo(localEpisode.Path).Directory; + var fileInfo = localEpisode.FileEpisodeInfo; + var folderInfo = localEpisode.FolderEpisodeInfo; - if (dirInfo == null) + if (folderInfo != null && folderInfo.IsPossibleSceneSeasonSpecial) { - return Decision.Accept(); + folderInfo = _parsingService.ParseSpecialEpisodeTitle(folderInfo, folderInfo.ReleaseTitle, localEpisode.Series.TvdbId, 0); } - var folderInfo = Parser.Parser.ParseTitle(dirInfo.Name); - if (folderInfo == null) { return Decision.Accept(); @@ -40,12 +42,12 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications return Decision.Accept(); } - if (folderInfo.FullSeason) + if (folderInfo.SeasonNumber != fileInfo.SeasonNumber) { - return Decision.Accept(); + return Decision.Reject("Season number {0} was unexpected considering the folder name {1}", fileInfo.SeasonNumber, folderInfo.ReleaseTitle); } - var unexpected = localEpisode.ParsedEpisodeInfo.EpisodeNumbers.Where(f => !folderInfo.EpisodeNumbers.Contains(f)).ToList(); + var unexpected = fileInfo.EpisodeNumbers.Where(f => !folderInfo.EpisodeNumbers.Contains(f)).ToList(); if (unexpected.Any()) { @@ -53,10 +55,10 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications if (unexpected.Count == 1) { - return Decision.Reject("Episode Number {0} was unexpected considering the {1} folder name", unexpected.First(), dirInfo.Name); + return Decision.Reject("Episode number {0} was unexpected considering the {1} folder name", unexpected.First(), folderInfo.ReleaseTitle); } - return Decision.Reject("Episode Numbers {0} were unexpected considering the {1} folder name", string.Join(", ", unexpected), dirInfo.Name); + return Decision.Reject("Episode numbers {0} were unexpected considering the {1} folder name", string.Join(", ", unexpected), folderInfo.ReleaseTitle); } return Decision.Accept(); diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs index c7b61d802..731f69f9c 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs @@ -1,5 +1,8 @@ -using NLog; +using System; +using NLog; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications @@ -16,7 +19,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { if (localEpisode.ExistingFile) { @@ -24,15 +27,23 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications return Decision.Accept(); } - var sample = _detectSample.IsSample(localEpisode.Series, - localEpisode.Quality, - localEpisode.Path, - localEpisode.Size, - localEpisode.IsSpecial); - - if (sample) + try { - return Decision.Reject("Sample"); + var sample = _detectSample.IsSample(localEpisode.Series, localEpisode.Path, localEpisode.IsSpecial); + + if (sample == DetectSampleResult.Sample) + { + return Decision.Reject("Sample"); + } + + else if (sample == DetectSampleResult.Indeterminate) + { + return Decision.Reject("Unable to determine if file is a sample"); + } + } + catch (InvalidSeasonException e) + { + _logger.Warn(e, "Invalid season detected during sample check"); } 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/EpisodeImportFailedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportFailedEvent.cs new file mode 100644 index 000000000..7a85ff891 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportFailedEvent.cs @@ -0,0 +1,29 @@ +using System; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class EpisodeImportFailedEvent : IEvent + { + public Exception Exception { get; set; } + public LocalEpisode EpisodeInfo { get; } + public bool NewDownload { get; } + public string DownloadClient { get; } + public string DownloadId { get; } + + public EpisodeImportFailedEvent(Exception exception, LocalEpisode episodeInfo, bool newDownload, DownloadClientItem downloadClientItem) + { + Exception = exception; + EpisodeInfo = episodeInfo; + NewDownload = newDownload; + + if (downloadClientItem != null) + { + DownloadClient = downloadClientItem.DownloadClient; + DownloadId = downloadClientItem.DownloadId; + } + } + } +} 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..15a8ab5dc --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Net; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging; +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, + IHandle + { + private readonly IDiskProvider _diskProvider; + private readonly IRecycleBinProvider _recycleBinProvider; + private readonly IMediaFileService _mediaFileService; + private readonly ISeriesService _seriesService; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public MediaFileDeletionService(IDiskProvider diskProvider, + IRecycleBinProvider recycleBinProvider, + IMediaFileService mediaFileService, + ISeriesService seriesService, + IConfigService configService, + Logger logger) + { + _diskProvider = diskProvider; + _recycleBinProvider = recycleBinProvider; + _mediaFileService = mediaFileService; + _seriesService = seriesService; + _configService = configService; + _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)) + { + _logger.Warn("Series' root folder ({0}) doesn't exist.", rootFolder); + throw new NzbDroneClientException(HttpStatusCode.Conflict, "Series' root folder ({0}) doesn't exist.", rootFolder); + } + + if (_diskProvider.GetDirectories(rootFolder).Empty()) + { + _logger.Warn("Series' root folder ({0}) is empty.", rootFolder); + 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) + { + var series = message.Series; + var allSeries = _seriesService.GetAllSeries(); + + foreach (var s in allSeries) + { + if (s.Id == series.Id) continue; + + if (series.Path.IsParentPath(s.Path)) + { + _logger.Error("Series path: '{0}' is a parent of another series, not deleting files.", series.Path); + return; + } + + if (series.Path.PathEquals(s.Path)) + { + _logger.Error("Series path: '{0}' is the same as another series, not deleting files.", series.Path); + return; + } + } + + if (_diskProvider.FolderExists(message.Series.Path)) + { + _recycleBinProvider.DeleteFolder(message.Series.Path); + } + } + } + + [EventHandleOrder(EventHandleOrder.Last)] + public void Handle(EpisodeFileDeletedEvent message) + { + if (message.Reason == DeleteMediaFileReason.Upgrade) + { + return; + } + + if (_configService.DeleteEmptyFolders) + { + var series = message.EpisodeFile.Series.Value; + var seasonFolder = message.EpisodeFile.Path.GetParentPath(); + + if (_diskProvider.GetFiles(series.Path, SearchOption.AllDirectories).Empty()) + { + _diskProvider.DeleteFolder(series.Path, true); + } + else if (_diskProvider.GetFiles(seasonFolder, SearchOption.AllDirectories).Empty()) + { + _diskProvider.RemoveEmptySubfolders(seasonFolder); + } + } + } + } +} 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..c6bf6a977 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs @@ -0,0 +1,448 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +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 audioChannels = FormatAudioChannelsFromAudioChannelPositions(mediaInfo); + + if (audioChannels == null) + { + audioChannels = FormatAudioChannelsFromAudioChannelPositionsText(mediaInfo); + } + + if (audioChannels == null) + { + audioChannels = FormatAudioChannelsFromAudioChannels(mediaInfo); + } + + return audioChannels ?? 0; + } + + 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.Trim().EqualsIgnoreCase("mp3")) + { + return "MP3"; + } + + 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("Opus")) + { + return "Opus"; + } + + 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; + + var result = videoFormat; + + if (videoFormat.IsNullOrWhiteSpace()) + { + return result; + } + + if (videoFormat == "x264") + { + return "x264"; + } + + if (videoFormat == "AVC" || videoFormat == "V.MPEG4/ISO/AVC") + { + if (videoCodecLibrary.StartsWithIgnoreCase("x264")) + { + return "x264"; + } + + return GetSceneNameMatch(sceneName, "AVC", "x264", "h264"); + } + + if (videoFormat == "HEVC" || videoFormat == "V_MPEGH/ISO/HEVC") + { + if (videoCodecLibrary.StartsWithIgnoreCase("x265")) + { + return "x265"; + } + + return GetSceneNameMatch(sceneName, "HEVC", "x265", "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") || + videoCodecLibrary.StartsWithIgnoreCase("XviD")) + { + return "XviD"; + } + + if (videoCodecID.ContainsIgnoreCase("DIV3") || + videoCodecID.ContainsIgnoreCase("DIVX") || + videoCodecID.ContainsIgnoreCase("DX50") || + videoCodecLibrary.StartsWithIgnoreCase("DivX")) + { + return "DivX"; + } + } + + if (videoFormat == "MPEG-4 Visual" || videoFormat == "mp4v") + { + result = GetSceneNameMatch(sceneName, "XviD", "DivX", ""); + if (result.IsNotNullOrWhiteSpace()) + { + return result; + } + } + + if (videoFormat == "VC-1") + { + return "VC1"; + } + + if (videoFormat.EqualsIgnoreCase("VP6") || videoFormat.EqualsIgnoreCase("VP7") || + videoFormat.EqualsIgnoreCase("VP8") || videoFormat.EqualsIgnoreCase("VP9")) + { + return videoFormat.ToUpperInvariant(); + } + + if (videoFormat == "WMV2") + { + return "WMV"; + } + + if (videoFormat.EqualsIgnoreCase("DivX") || videoFormat.EqualsIgnoreCase("div3")) + { + return "DivX"; + } + + 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 result; + } + + 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 decimal? FormatAudioChannelsFromAudioChannelPositions(MediaInfoModel mediaInfo) + { + var audioChannelPositions = mediaInfo.AudioChannelPositions; + + if (audioChannelPositions.IsNullOrWhiteSpace()) + { + return null; + } + + try + { + Logger.Debug("Formatting audio channels using 'AudioChannelPositions', with a value of: '{0}'", audioChannelPositions); + + if (audioChannelPositions.Contains("+")) + { + return audioChannelPositions.Split('+') + .Sum(s => decimal.Parse(s.Trim(), CultureInfo.InvariantCulture)); + } + + + return Regex.Replace(audioChannelPositions, @"^\d+\sobjects", "", RegexOptions.Compiled | RegexOptions.IgnoreCase) + .Replace("Object Based / ", "") + .Split(new string[] { " / " }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault() + ?.Split('/') + .Sum(s => decimal.Parse(s, CultureInfo.InvariantCulture)); + } + catch (Exception e) + { + Logger.Warn(e, "Unable to format audio channels using 'AudioChannelPositions'"); + } + + return null; + } + + private static decimal? FormatAudioChannelsFromAudioChannelPositionsText(MediaInfoModel mediaInfo) + { + var audioChannelPositionsText = mediaInfo.AudioChannelPositionsText; + var audioChannels = mediaInfo.AudioChannels; + + if (audioChannelPositionsText.IsNullOrWhiteSpace()) + { + return null; + } + + try + { + Logger.Debug("Formatiting audio channels using 'AudioChannelPositionsText', with a value of: '{0}'", audioChannelPositionsText); + + return mediaInfo.AudioChannelPositionsText.ContainsIgnoreCase("LFE") ? audioChannels - 1 + 0.1m : audioChannels; + } + catch (Exception e) + { + Logger.Warn(e, "Unable to format audio channels using 'AudioChannelPositionsText'"); + } + + return null; + } + + private static decimal? FormatAudioChannelsFromAudioChannels(MediaInfoModel mediaInfo) + { + var audioChannels = mediaInfo.AudioChannels; + + if (mediaInfo.SchemaRevision >= 3) + { + Logger.Debug("Formatiting audio channels using 'AudioChannels', with a value of: '{0}'", audioChannels); + + return audioChannels; + } + + return null; + } + + private static string GetSceneNameMatch(string sceneName, params string[] tokens) + { + sceneName = sceneName.IsNotNullOrWhiteSpace() ? Parser.Parser.RemoveFileExtension(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..23026f507 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs @@ -18,8 +18,6 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo private readonly IConfigService _configService; private readonly Logger _logger; - private const int CURRENT_MEDIA_INFO_SCHEMA_REVISION = 3; - public UpdateMediaInfoService(IDiskProvider diskProvider, IMediaFileService mediaFileService, IVideoFileInfoReader videoFileInfoReader, @@ -49,7 +47,6 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo if (mediaFile.MediaInfo != null) { - mediaFile.MediaInfo.SchemaRevision = CURRENT_MEDIA_INFO_SCHEMA_REVISION; _mediaFileService.Update(mediaFile); _logger.Debug("Updated MediaInfo for '{0}'", path); } @@ -65,7 +62,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 < VideoFileInfoReader.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..7a34341f6 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; using System.IO; using NLog; @@ -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 @@ -17,6 +17,8 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo private readonly IDiskProvider _diskProvider; private readonly Logger _logger; + public const int MINIMUM_MEDIA_INFO_SCHEMA_REVISION = 3; + public const int CURRENT_MEDIA_INFO_SCHEMA_REVISION = 4; public VideoFileInfoReader(IDiskProvider diskProvider, Logger logger) { @@ -34,6 +36,8 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo MediaInfo mediaInfo = null; + // TODO: Cache media info by path, mtime and length so we don't need to read files multiple times + try { mediaInfo = new MediaInfo(); @@ -104,58 +108,49 @@ 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, - ScanType = scanType + ScanType = scanType, + SchemaRevision = CURRENT_MEDIA_INFO_SCHEMA_REVISION }; return mediaInfoModel; @@ -181,16 +176,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/MediaFiles/UpdateEpisodeFileService.cs b/src/NzbDrone.Core/MediaFiles/UpdateEpisodeFileService.cs index d157a6147..cdc8454ec 100644 --- a/src/NzbDrone.Core/MediaFiles/UpdateEpisodeFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/UpdateEpisodeFileService.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.Configuration; @@ -25,6 +26,7 @@ namespace NzbDrone.Core.MediaFiles private readonly IConfigService _configService; private readonly IEpisodeService _episodeService; private readonly Logger _logger; + private static readonly DateTime EpochTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); public UpdateEpisodeFileService(IDiskProvider diskProvider, IConfigService configService, @@ -77,6 +79,75 @@ namespace NzbDrone.Core.MediaFiles return false; } + private bool ChangeFileDateToLocalAirDate(string filePath, string fileDate, string fileTime) + { + DateTime airDate; + + if (DateTime.TryParse(fileDate + ' ' + fileTime, out airDate)) + { + // avoiding false +ve checks and set date skewing by not using UTC (Windows) + DateTime oldDateTime = _diskProvider.FileGetLastWrite(filePath); + + if (OsInfo.IsNotWindows && airDate < EpochTime) + { + _logger.Debug("Setting date of file to 1970-01-01 as actual airdate is before that time and will not be set properly"); + airDate = EpochTime; + } + + if (!DateTime.Equals(airDate, oldDateTime)) + { + try + { + _diskProvider.FileSetLastWriteTime(filePath, airDate); + _logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", filePath, oldDateTime, airDate); + + return true; + } + + catch (Exception ex) + { + _logger.Warn(ex, "Unable to set date of file [" + filePath + "]"); + } + } + } + + else + { + _logger.Debug("Could not create valid date to change file [{0}]", filePath); + } + + return false; + } + + private bool ChangeFileDateToUtcAirDate(string filePath, DateTime airDateUtc) + { + DateTime oldLastWrite = _diskProvider.FileGetLastWrite(filePath); + + if (OsInfo.IsNotWindows && airDateUtc < EpochTime) + { + _logger.Debug("Setting date of file to 1970-01-01 as actual airdate is before that time and will not be set properly"); + airDateUtc = EpochTime; + } + + if (!DateTime.Equals(airDateUtc, oldLastWrite)) + { + try + { + _diskProvider.FileSetLastWriteTime(filePath, airDateUtc); + _logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", filePath, oldLastWrite, airDateUtc); + + return true; + } + + catch (Exception ex) + { + _logger.Warn(ex, "Unable to set date of file [" + filePath + "]"); + } + } + + return false; + } + public void Handle(SeriesScannedEvent message) { if (_configService.FileDate == FileDateType.None) @@ -112,62 +183,5 @@ namespace NzbDrone.Core.MediaFiles _logger.ProgressDebug("No file dates changed for {0}", message.Series.Title); } } - - private bool ChangeFileDateToLocalAirDate(string filePath, string fileDate, string fileTime) - { - DateTime airDate; - - if (DateTime.TryParse(fileDate + ' ' + fileTime, out airDate)) - { - // avoiding false +ve checks and set date skewing by not using UTC (Windows) - DateTime oldDateTime = _diskProvider.FileGetLastWrite(filePath); - - if (!DateTime.Equals(airDate, oldDateTime)) - { - try - { - _diskProvider.FileSetLastWriteTime(filePath, airDate); - _logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", filePath, oldDateTime, airDate); - - return true; - } - - catch (Exception ex) - { - _logger.Warn(ex, "Unable to set date of file [" + filePath + "]"); - } - } - } - - else - { - _logger.Debug("Could not create valid date to change file [{0}]", filePath); - } - - return false; - } - - private bool ChangeFileDateToUtcAirDate(string filePath, DateTime airDateUtc) - { - DateTime oldLastWrite = _diskProvider.FileGetLastWrite(filePath); - - if (!DateTime.Equals(airDateUtc, oldLastWrite)) - { - try - { - _diskProvider.FileSetLastWriteTime(filePath, airDateUtc); - _logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", filePath, oldLastWrite, airDateUtc); - - return true; - } - - catch (Exception ex) - { - _logger.Warn(ex, "Unable to set date of file [" + filePath + "]"); - } - } - - return false; - } } } diff --git a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs index d6c270d2c..2bcd7b25a 100644 --- a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs @@ -1,8 +1,9 @@ -using System.IO; +using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles @@ -39,13 +40,22 @@ namespace NzbDrone.Core.MediaFiles var existingFiles = localEpisode.Episodes .Where(e => e.EpisodeFileId > 0) .Select(e => e.EpisodeFile.Value) - .GroupBy(e => e.Id); + .GroupBy(e => e.Id) + .ToList(); + + var rootFolder = _diskProvider.GetParentFolder(localEpisode.Series.Path); + + // If there are existing episode files and the root folder is missing, throw, so the old file isn't left behind during the import process. + if (existingFiles.Any() && !_diskProvider.FolderExists(rootFolder)) + { + throw new RootFolderNotFoundException($"Root folder '{rootFolder}' was not found."); + } foreach (var existingFile in existingFiles) { var file = existingFile.First(); var episodeFilePath = Path.Combine(localEpisode.Series.Path, file.RelativePath); - var subfolder = _diskProvider.GetParentFolder(localEpisode.Series.Path).GetRelativePath(_diskProvider.GetParentFolder(episodeFilePath)); + var subfolder = rootFolder.GetRelativePath(_diskProvider.GetParentFolder(episodeFilePath)); if (_diskProvider.FileExists(episodeFilePath)) { 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/EventHandleOrderAttribute.cs b/src/NzbDrone.Core/Messaging/EventHandleOrderAttribute.cs new file mode 100644 index 000000000..6af307ecd --- /dev/null +++ b/src/NzbDrone.Core/Messaging/EventHandleOrderAttribute.cs @@ -0,0 +1,22 @@ +using System; + +namespace NzbDrone.Core.Messaging +{ + [AttributeUsage(AttributeTargets.Method)] + public class EventHandleOrderAttribute : Attribute + { + public EventHandleOrder EventHandleOrder { get; set; } + + public EventHandleOrderAttribute(EventHandleOrder eventHandleOrder) + { + EventHandleOrder = eventHandleOrder; + } + } + + public enum EventHandleOrder + { + First, + Any, + Last + } +} diff --git a/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs b/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs index a66d22c2c..4f49324a9 100644 --- a/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs +++ b/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using NLog; using NzbDrone.Common; @@ -13,12 +15,38 @@ namespace NzbDrone.Core.Messaging.Events private readonly Logger _logger; private readonly IServiceFactory _serviceFactory; private readonly TaskFactory _taskFactory; + private readonly Dictionary _eventSubscribers; + + private class EventSubscribers where TEvent : class, IEvent + { + private IServiceFactory _serviceFactory; + + public IHandle[] _syncHandlers; + public IHandleAsync[] _asyncHandlers; + public IHandleAsync[] _globalHandlers; + + public EventSubscribers(IServiceFactory serviceFactory) + { + _serviceFactory = serviceFactory; + + _syncHandlers = serviceFactory.BuildAll>() + .OrderBy(GetEventHandleOrder) + .ToArray(); + + _globalHandlers = serviceFactory.BuildAll>() + .ToArray(); + + _asyncHandlers = serviceFactory.BuildAll>() + .ToArray(); + } + } public EventAggregator(Logger logger, IServiceFactory serviceFactory) { _logger = logger; _serviceFactory = serviceFactory; _taskFactory = new TaskFactory(); + _eventSubscribers = new Dictionary(); } public void PublishEvent(TEvent @event) where TEvent : class, IEvent @@ -46,9 +74,21 @@ namespace NzbDrone.Core.Messaging.Events _logger.Trace("Publishing {0}", eventName); + EventSubscribers subscribers; + lock (_eventSubscribers) + { + object target; + if (!_eventSubscribers.TryGetValue(eventName, out target)) + { + _eventSubscribers[eventName] = target = new EventSubscribers(_serviceFactory); + } + + subscribers = target as EventSubscribers; + } //call synchronous handlers first. - foreach (var handler in _serviceFactory.BuildAll>()) + var handlers = subscribers._syncHandlers; + foreach (var handler in handlers) { try { @@ -62,7 +102,18 @@ namespace NzbDrone.Core.Messaging.Events } } - foreach (var handler in _serviceFactory.BuildAll>()) + foreach (var handler in subscribers._globalHandlers) + { + var handlerLocal = handler; + + _taskFactory.StartNew(() => + { + handlerLocal.HandleAsync(@event); + }, TaskCreationOptions.PreferFairness) + .LogExceptions(); + } + + foreach (var handler in subscribers._asyncHandlers) { var handlerLocal = handler; @@ -85,5 +136,25 @@ namespace NzbDrone.Core.Messaging.Events return string.Format("{0}<{1}>", eventType.Name.Remove(eventType.Name.IndexOf('`')), eventType.GetGenericArguments()[0].Name); } + + internal static int GetEventHandleOrder(IHandle eventHandler) where TEvent : class, IEvent + { + // TODO: Convert "Handle" to nameof(eventHandler.Handle) after .net 4.5 + var method = eventHandler.GetType().GetMethod("Handle", new Type[] {typeof(TEvent)}); + + if (method == null) + { + return (int) EventHandleOrder.Any; + } + + var attribute = method.GetCustomAttributes(typeof(EventHandleOrderAttribute), true).FirstOrDefault() as EventHandleOrderAttribute; + + if (attribute == null) + { + return (int) EventHandleOrder.Any; + } + + return (int)attribute.EventHandleOrder; + } } } diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs index 69733ae54..05968d604 100644 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Collections.Specialized; using System.IO; using System.Linq; @@ -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_Indexer", remoteEpisode.Release.Indexer ?? string.Empty); environmentVariables.Add("Sonarr_Release_Size", remoteEpisode.Release.Size.ToString()); - environmentVariables.Add("Sonarr_Release_ReleaseGroup", releaseGroup); + 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 ?? string.Empty); + 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/Join/JoinProxy.cs b/src/NzbDrone.Core/Notifications/Join/JoinProxy.cs index 9aed6786c..51666d959 100644 --- a/src/NzbDrone.Core/Notifications/Join/JoinProxy.cs +++ b/src/NzbDrone.Core/Notifications/Join/JoinProxy.cs @@ -1,6 +1,7 @@ -using System; +using System; using FluentValidation.Results; using NLog; +using NzbDrone.Common.Extensions; using RestSharp; using NzbDrone.Core.Rest; using NzbDrone.Common.Serializer; @@ -75,7 +76,11 @@ namespace NzbDrone.Core.Notifications.Join var client = RestClientFactory.BuildClient(URL); - if (!string.IsNullOrEmpty(settings.DeviceIds)) + if (settings.DeviceNames.IsNotNullOrWhiteSpace()) + { + request.AddParameter("deviceNames", settings.DeviceNames); + } + else if (settings.DeviceIds.IsNotNullOrWhiteSpace()) { request.AddParameter("deviceIds", settings.DeviceIds); } diff --git a/src/NzbDrone.Core/Notifications/Join/JoinSettings.cs b/src/NzbDrone.Core/Notifications/Join/JoinSettings.cs index 29d750782..ac305867f 100644 --- a/src/NzbDrone.Core/Notifications/Join/JoinSettings.cs +++ b/src/NzbDrone.Core/Notifications/Join/JoinSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -10,7 +10,7 @@ namespace NzbDrone.Core.Notifications.Join public JoinSettingsValidator() { RuleFor(s => s.ApiKey).NotEmpty(); - RuleFor(s => s.DeviceIds).Matches(@"\A\S+\z").When(s => !string.IsNullOrEmpty(s.DeviceIds)); + RuleFor(s => s.DeviceIds).Empty().WithMessage("Use Device Names instead"); } } @@ -21,9 +21,12 @@ namespace NzbDrone.Core.Notifications.Join [FieldDefinition(0, Label = "API Key", HelpText = "The API Key from your Join account settings (click Join API button).", HelpLink = "https://joinjoaomgcd.appspot.com/")] public string ApiKey { get; set; } - [FieldDefinition(1, Label = "Device IDs", HelpText = "Comma separated list of Device IDs you'd like to send notifications to. If unset, all devices will receive notifications.", HelpLink = "https://joinjoaomgcd.appspot.com/")] + [FieldDefinition(1, Label = "Device IDs", HelpText = "Deprecated, use Device Names instead. Comma separated list of Device IDs you'd like to send notifications to. If unset, all devices will receive notifications.")] public string DeviceIds { get; set; } + [FieldDefinition(2, Label = "Device Names", HelpText = "Comma separated list of full or partial device names you'd like to send notifications to. If unset, all devices will receive notifications.", HelpLink = "https://joaoapps.com/join/api/")] + public string DeviceNames { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); 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..ef0afaa21 100644 --- a/src/NzbDrone.Core/Notifications/Slack/Payloads/SlackPayload.cs +++ b/src/NzbDrone.Core/Notifications/Slack/Payloads/SlackPayload.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; namespace NzbDrone.Core.Notifications.Slack.Payloads @@ -12,6 +12,11 @@ namespace NzbDrone.Core.Notifications.Slack.Payloads [JsonProperty("icon_emoji")] public string IconEmoji { get; set; } + [JsonProperty("icon_url")] + public string IconUrl { get; set; } + + public string Channel { 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..c8afe6e00 100644 --- a/src/NzbDrone.Core/Notifications/Slack/Slack.cs +++ b/src/NzbDrone.Core/Notifications/Slack/Slack.cs @@ -1,8 +1,10 @@ -using System; +using System; 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,37 @@ 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 channel = Settings.Channel; + + 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); + payload.IconEmoji = icon; + } + else + { + payload.IconUrl = icon; + } } - catch (RestException ex) + + if (channel.IsNotNullOrWhiteSpace()) { - _logger.Error(ex, "Unable to post payload {0}", payload); - throw new SlackExeption("Unable to post payload", ex); + payload.Channel = channel; } + + 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..89608f083 100644 --- a/src/NzbDrone.Core/Notifications/Slack/SlackSettings.cs +++ b/src/NzbDrone.Core/Notifications/Slack/SlackSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -24,9 +24,12 @@ 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; } + [FieldDefinition(3, Label = "Channel", HelpText = "Overrides the default channel for the incoming webhook (#other-channel)", Type = FieldType.Textbox)] + public string Channel { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Notifications/Telegram/TelegramService.cs b/src/NzbDrone.Core/Notifications/Telegram/TelegramService.cs index a0cb33a82..35f2193d5 100644 --- a/src/NzbDrone.Core/Notifications/Telegram/TelegramService.cs +++ b/src/NzbDrone.Core/Notifications/Telegram/TelegramService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using FluentValidation.Results; using NLog; @@ -6,6 +6,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Serializer; using RestSharp; using NzbDrone.Core.Rest; +using System.Web; namespace NzbDrone.Core.Notifications.Telegram { @@ -28,13 +29,13 @@ namespace NzbDrone.Core.Notifications.Telegram public void SendNotification(string title, string message, TelegramSettings settings) { //Format text to add the title before and bold using markdown - var text = $"*{title}*\n{message}"; + var text = $"{HttpUtility.HtmlEncode(title)}\n{HttpUtility.HtmlEncode(message)}"; var client = RestClientFactory.BuildClient(URL); var request = new RestRequest("bot{token}/sendmessage", Method.POST); request.AddUrlSegment("token", settings.BotToken); request.AddParameter("chat_id", settings.ChatId); - request.AddParameter("parse_mode", "Markdown"); + request.AddParameter("parse_mode", "HTML"); request.AddParameter("text", text); client.ExecuteAndValidate(request); 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..5d5caaeab --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs @@ -0,0 +1,46 @@ +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Rest; +using NzbDrone.Common.Extensions; + +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()); + + if (settings.Username.IsNotNullOrWhiteSpace() || settings.Password.IsNotNullOrWhiteSpace()) + { + request.AddBasicAuthentication(settings.Username, settings.Password); + } + _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/Notifications/Webhook/WebhookSettings.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs index 38ac3ee12..1219c70b4 100644 --- a/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs @@ -29,6 +29,12 @@ namespace NzbDrone.Core.Notifications.Webhook [FieldDefinition(1, Label = "Method", Type = FieldType.Select, SelectOptions = typeof(WebhookMethod), HelpText = "Which HTTP method to use submit to the Webservice")] public int Method { get; set; } + [FieldDefinition(2, Label = "Username")] + public string Username { get; set; } + + [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] + public string Password { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Notifications/Xbmc/XbmcJsonApiProxy.cs b/src/NzbDrone.Core/Notifications/Xbmc/XbmcJsonApiProxy.cs index 943a80cd3..37040ad1d 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/XbmcJsonApiProxy.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/XbmcJsonApiProxy.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Serializer; @@ -122,13 +122,14 @@ namespace NzbDrone.Core.Notifications.Xbmc private void CheckForError(IRestResponse response) { - _logger.Debug("Looking for error in response: {0}", response); if (string.IsNullOrWhiteSpace(response.Content)) { throw new XbmcJsonException("Invalid response from XBMC, the response is not valid JSON"); } + _logger.Trace("Looking for error in response, {0}", response.Content); + if (response.Content.StartsWith("{\"error\"")) { var error = Json.Deserialize(response.Content); diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 9e238632b..49383c194 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -81,7 +81,7 @@ True - ..\packages\NLog.4.4.3\lib\net40\NLog.dll + ..\packages\NLog.4.5.3\lib\net40-client\NLog.dll ..\packages\OAuth.1.0.3\lib\net40\OAuth.dll @@ -95,9 +95,14 @@ True + + + + + @@ -124,6 +129,7 @@ + @@ -137,9 +143,11 @@ + + @@ -222,6 +230,8 @@ + + @@ -248,6 +258,11 @@ + + + + + @@ -282,11 +297,15 @@ + Code + + + @@ -315,11 +334,13 @@ + + @@ -330,16 +351,17 @@ + - - + + @@ -356,6 +378,7 @@ + @@ -404,9 +427,7 @@ - - Code - + @@ -432,6 +453,7 @@ + @@ -483,12 +505,19 @@ + + + + + + + @@ -502,7 +531,7 @@ - + @@ -520,6 +549,7 @@ + @@ -532,6 +562,7 @@ + @@ -540,6 +571,7 @@ + @@ -551,6 +583,11 @@ + + + + + @@ -561,10 +598,12 @@ + + @@ -575,14 +614,19 @@ + + + + + @@ -594,6 +638,7 @@ + @@ -621,7 +666,9 @@ + + @@ -637,6 +684,7 @@ + @@ -659,6 +707,8 @@ + + @@ -722,6 +772,20 @@ + + + + + + + + + + + + + + @@ -749,7 +813,11 @@ + + + + @@ -757,22 +825,25 @@ - + + + Code + @@ -805,6 +876,7 @@ + @@ -866,6 +938,7 @@ + @@ -877,26 +950,33 @@ + + + + - + + + + @@ -1035,9 +1115,11 @@ + + @@ -1058,6 +1140,7 @@ + @@ -1069,6 +1152,10 @@ + + + + @@ -1130,6 +1217,7 @@ + diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 31cbd53ef..34f1e19c1 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -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; @@ -22,6 +23,7 @@ namespace NzbDrone.Core.Organizer BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec); string GetSeriesFolder(Series series, NamingConfig namingConfig = null); string GetSeasonFolder(Series series, int seasonNumber, NamingConfig namingConfig = null); + bool RequiresEpisodeTitle(Series series, List episodes); } public class FileNameBuilder : IBuildFileNames @@ -30,6 +32,7 @@ namespace NzbDrone.Core.Organizer private readonly IQualityDefinitionService _qualityDefinitionService; private readonly ICached _episodeFormatCache; private readonly ICached _absoluteEpisodeFormatCache; + private readonly ICached _requiresEpisodeTitleCache; private readonly Logger _logger; private static readonly Regex TitleRegex = new Regex(@"\{(?[- ._\[(]*)(?(?:[a-z0-9]+)(?:(?[- ._]+)(?:[a-z0-9]+))?)(?::(?[a-z0-9]+))?(?[- ._)\]]*)\}", @@ -52,7 +55,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 +69,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, @@ -75,6 +80,7 @@ namespace NzbDrone.Core.Organizer _qualityDefinitionService = qualityDefinitionService; _episodeFormatCache = cacheManager.GetCache(GetType(), "episodeFormat"); _absoluteEpisodeFormatCache = cacheManager.GetCache(GetType(), "absoluteEpisodeFormat"); + _requiresEpisodeTitleCache = cacheManager.GetCache(GetType(), "requiresEpisodeTitle"); _logger = logger; } @@ -128,7 +134,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 +258,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; @@ -263,7 +274,7 @@ namespace NzbDrone.Core.Organizer result = result.Replace(badCharacters[i], replace ? goodCharacters[i] : string.Empty); } - return result.Trim(); + return result.TrimStart(' ', '.').TrimEnd(' '); } public static string CleanFolderName(string name) @@ -272,10 +283,45 @@ namespace NzbDrone.Core.Organizer return name.Trim(' ', '.'); } + public bool RequiresEpisodeTitle(Series series, List episodes) + { + var namingConfig = _namingConfigService.GetConfig(); + var pattern = namingConfig.StandardEpisodeFormat; + + if (series.SeriesType == SeriesTypes.Daily) + { + pattern = namingConfig.DailyEpisodeFormat; + } + + if (series.SeriesType == SeriesTypes.Anime && episodes.All(e => e.AbsoluteEpisodeNumber.HasValue)) + { + pattern = namingConfig.AnimeEpisodeFormat; + } + + return _requiresEpisodeTitleCache.Get(pattern, () => + { + var matches = TitleRegex.Matches(pattern); + + foreach (Match match in matches) + { + var token = match.Groups["token"].Value; + + if (FileNameBuilderTokenEqualityComparer.Instance.Equals(token, "{Episode Title}") || + FileNameBuilderTokenEqualityComparer.Instance.Equals(token, "{Episode CleanTitle}")) + { + return true; + } + } + + return false; + }); + } + private void AddSeriesTokens(Dictionary> tokenHandlers, Series series) { 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 +491,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 +511,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 +525,14 @@ 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 AudioLanguages}"] = m => mediaInfoAudioLanguages; - tokenHandlers["{MediaInfo Simple}"] = m => string.Format("{0} {1}", videoCodec, audioCodec); + tokenHandlers["{MediaInfo SubtitleLanguages}"] = m => mediaInfoSubtitleLanguages; - tokenHandlers["{MediaInfo Full}"] = m => string.Format("{0} {1}{2} {3}", videoCodec, audioCodec, mediaInfoAudioLanguages, mediaInfoSubtitleLanguages); + tokenHandlers["{MediaInfo Simple}"] = m => $"{videoCodec} {audioCodec}"; + + 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/Organizer/NamingConfigService.cs b/src/NzbDrone.Core/Organizer/NamingConfigService.cs index 1cbe993dc..8cd9d3234 100644 --- a/src/NzbDrone.Core/Organizer/NamingConfigService.cs +++ b/src/NzbDrone.Core/Organizer/NamingConfigService.cs @@ -21,8 +21,16 @@ namespace NzbDrone.Core.Organizer if (config == null) { - _repository.Insert(NamingConfig.Default); - config = _repository.Single(); + lock (_repository) + { + config = _repository.SingleOrDefault(); + + if (config == null) + { + _repository.Insert(NamingConfig.Default); + config = _repository.Single(); + } + } } return config; @@ -33,4 +41,4 @@ namespace NzbDrone.Core.Organizer _repository.Upsert(namingConfig); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Parser/InvalidSeasonException.cs b/src/NzbDrone.Core/Parser/InvalidSeasonException.cs new file mode 100644 index 000000000..23ce77954 --- /dev/null +++ b/src/NzbDrone.Core/Parser/InvalidSeasonException.cs @@ -0,0 +1,15 @@ +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Parser +{ + public class InvalidSeasonException : NzbDroneException + { + public InvalidSeasonException(string message, params object[] args) : base(message, args) + { + } + + public InvalidSeasonException(string message) : base(message) + { + } + } +} diff --git a/src/NzbDrone.Core/Parser/IsoLanguages.cs b/src/NzbDrone.Core/Parser/IsoLanguages.cs index ddbbe74c2..b87b7ea5b 100644 --- a/src/NzbDrone.Core/Parser/IsoLanguages.cs +++ b/src/NzbDrone.Core/Parser/IsoLanguages.cs @@ -28,7 +28,10 @@ namespace NzbDrone.Core.Parser // new IsoLanguage("nl", "nld", Language.Flemish), new IsoLanguage("el", "ell", Language.Greek), new IsoLanguage("ko", "kor", Language.Korean), - new IsoLanguage("hu", "hun", Language.Hungarian) + new IsoLanguage("hu", "hun", Language.Hungarian), + new IsoLanguage("he", "heb", Language.Hebrew), + new IsoLanguage("lt", "lit", Language.Lithuanian), + new IsoLanguage("cs", "ces", Language.Czech) }; public static IsoLanguage Find(string isoCode) diff --git a/src/NzbDrone.Core/Parser/Language.cs b/src/NzbDrone.Core/Parser/Language.cs index f85281dd1..0e9623377 100644 --- a/src/NzbDrone.Core/Parser/Language.cs +++ b/src/NzbDrone.Core/Parser/Language.cs @@ -24,6 +24,9 @@ Flemish = 19, Greek = 20, Korean = 21, - Hungarian = 22 + Hungarian = 22, + Hebrew = 23, + Lithuanian = 24, + Czech = 25 } } diff --git a/src/NzbDrone.Core/Parser/LanguageParser.cs b/src/NzbDrone.Core/Parser/LanguageParser.cs index a2de40b84..024a15c70 100644 --- a/src/NzbDrone.Core/Parser/LanguageParser.cs +++ b/src/NzbDrone.Core/Parser/LanguageParser.cs @@ -11,9 +11,13 @@ namespace NzbDrone.Core.Parser { private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(LanguageParser)); - private static readonly Regex LanguageRegex = new Regex(@"(?:\W|_)(?\b(?:ita|italian)\b)|(?german\b|videomann)|(?flemish)|(?greek)|(?(?:\W|_)(?:FR|VOSTFR)(?:\W|_))|(?\brus\b)|(?nl\W?subs?)|(?\b(?:HUNDUB|HUN)\b)", + private static readonly Regex LanguageRegex = new Regex(@"(?:\W|_)(?\b(?:ita|italian)\b)|(?german\b|videomann)|(?flemish)|(?greek)|(?(?:\W|_)(?:FR|VOSTFR)(?:\W|_))|(?\brus\b)|(?nl\W?subs?)|(?\b(?:HUNDUB|HUN)\b)|(?\bHebDub\b)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex CaseSensitiveLanguageRegex = new Regex(@"(?\bLT\b)|(?\bCZ\b)", + RegexOptions.Compiled); + + private static readonly Regex SubtitleLanguageRegex = new Regex(".+?[-_. ](?[a-z]{2,3})$", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static Language ParseLanguage(string title) @@ -77,31 +81,15 @@ namespace NzbDrone.Core.Parser if (lowerTitle.Contains("hungarian")) return Language.Hungarian; - var match = LanguageRegex.Match(title); + if (lowerTitle.Contains("hebrew")) + return Language.Hebrew; - if (match.Groups["italian"].Captures.Cast().Any()) - return Language.Italian; + var regexLanguage = RegexLanguage(title); - if (match.Groups["german"].Captures.Cast().Any()) - return Language.German; - - if (match.Groups["flemish"].Captures.Cast().Any()) - return Language.Flemish; - - if (match.Groups["greek"].Captures.Cast().Any()) - return Language.Greek; - - if (match.Groups["french"].Success) - return Language.French; - - if (match.Groups["russian"].Success) - return Language.Russian; - - if (match.Groups["dutch"].Success) - return Language.Dutch; - - if (match.Groups["hungarian"].Success) - return Language.Hungarian; + if (regexLanguage != Language.Unknown) + { + return regexLanguage; + } return Language.English; } @@ -140,5 +128,49 @@ namespace NzbDrone.Core.Parser return Language.Unknown; } + + private static Language RegexLanguage(string title) + { + // Case sensitive + var caseSensitiveMatch = CaseSensitiveLanguageRegex.Match(title); + + if (caseSensitiveMatch.Groups["lithuanian"].Captures.Cast().Any()) + return Language.Lithuanian; + + if (caseSensitiveMatch.Groups["czech"].Captures.Cast().Any()) + return Language.Czech; + + // Case insensitive + var match = LanguageRegex.Match(title); + + if (match.Groups["italian"].Captures.Cast().Any()) + return Language.Italian; + + if (match.Groups["german"].Captures.Cast().Any()) + return Language.German; + + if (match.Groups["flemish"].Captures.Cast().Any()) + return Language.Flemish; + + if (match.Groups["greek"].Captures.Cast().Any()) + return Language.Greek; + + if (match.Groups["french"].Success) + return Language.French; + + if (match.Groups["russian"].Success) + return Language.Russian; + + if (match.Groups["dutch"].Success) + return Language.Dutch; + + if (match.Groups["hungarian"].Success) + return Language.Hungarian; + + if (match.Groups["hebrew"].Success) + return Language.Hebrew; + + return Language.Unknown; + } } } diff --git a/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs b/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs index 67ec2d873..94a0ee5cd 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Collections.Generic; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Qualities; using NzbDrone.Core.Tv; using NzbDrone.Core.MediaFiles.MediaInfo; @@ -15,18 +16,34 @@ namespace NzbDrone.Core.Parser.Model public string Path { get; set; } public long Size { get; set; } - public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } + public ParsedEpisodeInfo FileEpisodeInfo { get; set; } + public ParsedEpisodeInfo DownloadClientEpisodeInfo { get; set; } + public ParsedEpisodeInfo FolderEpisodeInfo { get; set; } public Series Series { get; set; } public List Episodes { get; set; } public QualityModel Quality { get; set; } public MediaInfoModel MediaInfo { get; set; } public bool ExistingFile { get; set; } + public bool SceneSource { get; set; } + public string ReleaseGroup { get; set; } public int SeasonNumber { get { - return Episodes.Select(c => c.SeasonNumber).Distinct().Single(); + var seasons = Episodes.Select(c => c.SeasonNumber).Distinct().ToList(); + + if (seasons.Empty()) + { + throw new InvalidSeasonException("Expected one season, but found none"); + } + + if (seasons.Count > 1) + { + throw new InvalidSeasonException("Expected one season, but found {0} ({1})", seasons.Count, string.Join(", ", seasons)); + } + + return seasons.Single(); } } @@ -37,4 +54,4 @@ namespace NzbDrone.Core.Parser.Model return Path; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs index 256269c36..6dc338dcf 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; } @@ -15,9 +16,12 @@ namespace NzbDrone.Core.Parser.Model public string AirDate { get; set; } public Language Language { get; set; } public bool FullSeason { get; set; } + public bool IsPartialSeason { get; set; } + public bool IsSeasonExtra { get; set; } public bool Special { get; set; } public string ReleaseGroup { get; set; } public string ReleaseHash { get; set; } + public int SeasonPart { get; set; } public ParsedEpisodeInfo() { @@ -53,17 +57,29 @@ namespace NzbDrone.Core.Parser.Model { get { - // if we don't have eny episode numbers we are likely a special episode and need to do a search by episode title + // if we don't have any episode numbers we are likely a special episode and need to do a search by episode title return (AirDate.IsNullOrWhiteSpace() && SeriesTitle.IsNullOrWhiteSpace() && - (EpisodeNumbers.Length == 0 || SeasonNumber == 0) || - !SeriesTitle.IsNullOrWhiteSpace() && Special); + (EpisodeNumbers.Length == 0 || SeasonNumber == 0) || !SeriesTitle.IsNullOrWhiteSpace() && Special) || + EpisodeNumbers.Length == 1 && EpisodeNumbers[0] == 0; } //This prevents manually downloading a release from blowing up in mono //TODO: Is there a better way? private set {} } + public bool IsPossibleSceneSeasonSpecial + { + get + { + // SxxE00 episodes + return SeasonNumber != 0 && EpisodeNumbers.Length == 1 && EpisodeNumbers[0] == 0; + } + + //This prevents manually downloading a release from blowing up in mono + //TODO: Is there a better way? + private set { } + } public override string ToString() { @@ -89,4 +105,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/Model/RemoteEpisode.cs b/src/NzbDrone.Core/Parser/Model/RemoteEpisode.cs index 319606781..3b99f250c 100644 --- a/src/NzbDrone.Core/Parser/Model/RemoteEpisode.cs +++ b/src/NzbDrone.Core/Parser/Model/RemoteEpisode.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Parser.Model @@ -12,6 +13,7 @@ namespace NzbDrone.Core.Parser.Model public Series Series { get; set; } public List Episodes { get; set; } public bool DownloadAllowed { get; set; } + public TorrentSeedConfiguration SeedConfiguration { get; set; } public bool IsRecentEpisode() { @@ -23,4 +25,4 @@ namespace NzbDrone.Core.Parser.Model return Release.Title; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 4855926a9..05c920b13 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -26,8 +26,12 @@ namespace NzbDrone.Core.Parser new Regex(@"^(?:\W*S?(?(?\d{1,3}(?!\d+)))+){2,}", RegexOptions.IgnoreCase | RegexOptions.Compiled), - //Episodes without a title, Single (S01E05, 1x05) AND Multi (S01E04E05, 1x04x05, etc) - new Regex(@"^(?:S?(?(?\d{2,3}(?!\d+)))+)", + //Episodes without a title, Multi (S01E04E05, 1x04x05, etc) + new Regex(@"^(?:S?(?(?\d{2,3}(?!\d+))){2,})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Episodes without a title, Single (S01E05, 1x05) + new Regex(@"^(?:S?(?(?\d{2,3}(?!\d+))))", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Anime - [SubGroup] Title Episode Absolute Episode Number ([SubGroup] Series Title Episode 01) @@ -55,13 +59,25 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), //Anime - [SubGroup] Title Absolute Episode Number - new Regex(@"^\[(?.+?)\][-_. ]?(?.+?)[-_. ]+\(?(?:[-_. ]?(?<absoluteepisode>\d{2,3}(?!\d+)))+\)?(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)[-_. ]+\(?(?:[-_. ]?#?(?<absoluteepisode>\d{2,3}(?!\d+)))+\)?(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Multi-episode Repeated (S01E05 - S01E06, 1x05 - 1x06, etc) + new Regex(@"^(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+S?(?<season>(?<!\d+)(?:\d{1,2}|\d{4})(?!\d+))(?:(?:[ex]|[-_. ]e){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Single episodes with a title (S01E05, 1x05, etc) and trailing info in slashes + new Regex(@"^(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+S?(?<season>(?<!\d+)(?:\d{1,2})(?!\d+))(?:[ex]|\W[ex]|_){1,2}(?<episode>\d{2,3}(?!\d+|(?:[ex]|\W[ex]|_|-){1,2}\d+))).+?(?:\[.+?\])(?!\\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Anime - Title Season EpisodeNumber + Absolute Episode Number [SubGroup] - new Regex(@"^(?<title>.+?)(?:[-_\W](?<![()\[!]))+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]|\W[ex]){1,2}(?<episode>(?<!\d+)\d{2}(?!\d+)))+).+?(?:[-_. ]?(?<absoluteepisode>(?<!\d+)\d{3}(?!\d+)))+.+?\[(?<subgroup>.+?)\](?:$|\.mkv)", + new Regex(@"^(?<title>.+?)(?:[-_\W](?<![()\[!]))+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]|\W[ex]){1,2}(?<episode>(?<!\d+)\d{2}(?!\d+)))).+?(?:[-_. ]?(?<absoluteepisode>(?<!\d+)\d{3}(?!\d+)))+.+?\[(?<subgroup>.+?)\](?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Multi-Episode with a title (S01E05E06, S01E05-06, S01E05 E06, etc) and trailing info in slashes + new Regex(@"^(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+S?(?<season>(?<!\d+)(?:\d{1,2})(?!\d+))(?:[ex]|\W[ex]|_){1,2}(?<episode>\d{2,3}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{2,3}(?!\d+)))+).+?(?:\[.+?\])(?!\\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Anime - Title Absolute Episode Number [SubGroup] new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+(?<absoluteepisode>\d{3}(?!\d+)))+(?:.+?)\[(?<subgroup>.+?)\].*?(?<hash>\[\w{8}\])?(?:$|\.)", RegexOptions.IgnoreCase | RegexOptions.Compiled), @@ -78,10 +94,6 @@ namespace NzbDrone.Core.Parser new Regex(@"^(?<title>.+?)?\W*(?<airyear>\d{4})\W+(?<airmonth>[0-1][0-9])\W+(?<airday>[0-3][0-9])(?!\W+[0-3][0-9]).+?(?:s?(?<season>(?<!\d+)(?:\d{1,2})(?!\d+)))(?:[ex](?<episode>(?<!\d+)(?:\d{1,3})(?!\d+)))", RegexOptions.IgnoreCase | RegexOptions.Compiled), - //Multi-episode Repeated (S01E05 - S01E06, 1x05 - 1x06, etc) - new Regex(@"^(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+S?(?<season>(?<!\d+)(?:\d{1,2}|\d{4})(?!\d+))(?:(?:[ex]|[-_. ]e){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - //Episodes with a title, Single episodes (S01E05, 1x05, etc) & Multi-episode (S01E05E06, S01E05-06, S01E05 E06, etc) new Regex(@"^(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+S?(?<season>(?<!\d+)(?:\d{1,2})(?!\d+))(?:[ex]|\W[ex]|_){1,2}(?<episode>\d{2,3}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{2,3}(?!\d+)))*)\W?(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled), @@ -94,10 +106,18 @@ namespace NzbDrone.Core.Parser new Regex(@"^(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+(?<season>(?<!\d+)(?:\d{4})(?!\d+))(?:x|\Wx|_){1,2}(?<episode>\d{2,3}(?!\d+))(?:(?:\-|x|\Wx|_){1,2}(?<episode>\d{2,3}(?!\d+)))*)\W?(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Partial season pack + new Regex(@"^(?<title>.+?)(?:\W+S(?<season>(?<!\d+)(?:\d{1,2})(?!\d+))\W+(?:(?:Part\W?|(?<!\d+\W+)e)(?<seasonpart>\d{1,2}(?!\d+)))+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Mini-Series with year in title, treated as season 1, episodes are labelled as Part01, Part 01, Part.1 new Regex(@"^(?<title>.+?\d{4})(?:\W+(?:(?:Part\W?|e)(?<episode>\d{1,2}(?!\d+)))+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Mini-Series, treated as season 1, multi episodes are labelled as E1-E2 + new Regex(@"^(?<title>.+?)(?:\W[e])(?<episode>\d{2,3}(?!\d+))(?:(?:\-?[e])(?<episode>\d{2,3}(?!\d+)))+", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Mini-Series, treated as season 1, episodes are labelled as Part01, Part 01, Part.1 new Regex(@"^(?<title>.+?)(?:\W+(?:(?:Part\W?|(?<!\d+\W+)e)(?<episode>\d{1,2}(?!\d+)))+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), @@ -122,8 +142,8 @@ namespace NzbDrone.Core.Parser new Regex(@"^(?<title>.+?)[-_. ]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 @@ -158,22 +178,26 @@ namespace NzbDrone.Core.Parser new Regex(@"^(?<title>.+?)?(?:(?:[-_\W](?<![()\[!]))+(?<season>(?<!\d+)[1-9])(?<episode>[1-9][0-9]|[0][1-9])(?![a-z]|\d+))+", RegexOptions.IgnoreCase | RegexOptions.Compiled), - //Episodes with airdate - new Regex(@"^(?<title>.+?)?\W*(?<airyear>\d{4})\W+(?<airmonth>[0-1][0-9])\W+(?<airday>[0-3][0-9])(?!\W+[0-3][0-9])", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Supports 1103/1113 naming - new Regex(@"^(?<title>.+?)?(?:(?:[-_\W](?<![()\[!]))*(?<season>(?<!\d+|\(|\[|e|x)\d{2})(?<episode>(?<!e|x)\d{2}(?!p|i|\d+|\)|\]|\W\d+)))+(\W+|_|$)(?!\\)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - //4 digit episode number //Episodes without a title, Single (S01E05, 1x05) AND Multi (S01E04E05, 1x04x05, etc) new Regex(@"^(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{4}(?!\d+|i|p)))+)(\W+|_|$)(?!\\)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), + RegexOptions.IgnoreCase | RegexOptions.Compiled), //4 digit episode number //Episodes with a title, Single episodes (S01E05, 1x05, etc) & Multi-episode (S01E05E06, S01E05-06, S01E05 E06, etc) new Regex(@"^(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{4}(?!\d+|i|p)))+)\W?(?!\\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Episodes with airdate (2018.04.28) + new Regex(@"^(?<title>.+?)?\W*(?<airyear>\d{4})\W+(?<airmonth>[0-1][0-9])\W+(?<airday>[0-3][0-9])(?!\W+[0-3][0-9])", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Episodes with airdate (04.28.2018) + new Regex(@"^(?<title>.+?)?\W*(?<airmonth>[0-1][0-9])\W+(?<airday>[0-3][0-9])\W+(?<airyear>\d{4})(?!\d+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Supports 1103/1113 naming + new Regex(@"^(?<title>.+?)?(?:(?:[-_\W](?<![()\[!]))*(?<season>(?<!\d+|\(|\[|e|x)\d{2})(?<episode>(?<!e|x)\d{2}(?!p|i|\d+|\)|\]|\W\d+|\W(?:e|ep|x)\d+)))+(\W+|_|$)(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Episodes with single digit episode number (S01E1, S01E5E6, etc) @@ -184,6 +208,10 @@ namespace NzbDrone.Core.Parser new Regex(@"^(?:Season(?:_|-|\s|\.)(?<season>(?<!\d+)\d{1,2}(?!\d+)))(?:_|-|\s|\.)(?<episode>(?<!\d+)\d{1,2}(?!\d+))", RegexOptions.IgnoreCase | RegexOptions.Compiled), + //iTunes 1-05 Title (Quality).ext + new Regex(@"^(?:(?<season>(?<!\d+)(?:\d{1,2})(?!\d+))(?:-(?<episode>\d{2,3}(?!\d+))))", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Anime - Title Absolute Episode Number (e66) new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:(?:_|-|\s|\.)+(?:e|ep)(?<absoluteepisode>\d{2,3}))+.*?(?<hash>\[\w{8}\])?(?:$|\.)", RegexOptions.IgnoreCase | RegexOptions.Compiled), @@ -205,11 +233,21 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled) }; + + private static readonly Regex[] SpecialEpisodeTitleRegex = new Regex[] + { + new Regex(@"\.S\d+E00\.(?<episodetitle>.+?)(?:\.(?:720p|1080p|HDTV|WEB|WEBRip|WEB-DL)\.|$)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + new Regex(@"\.S\d+\.Special\.(?<episodetitle>.+?)(?:\.(?:720p|1080p|HDTV|WEB|WEBRip|WEB-DL)\.|$)", + RegexOptions.IgnoreCase | RegexOptions.Compiled) + }; + private static readonly Regex[] RejectHashedReleasesRegex = new Regex[] { // 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), @@ -243,21 +281,21 @@ namespace NzbDrone.Core.Parser private static readonly Regex SimpleTitleRegex = new Regex(@"(?:(480|720|1080|2160)[ip]|[xh][\W_]?26[45]|DD\W?5\W1|[<>?*:|]|848x480|1280x720|1920x1080|3840x2160|4096x2160|(8|10)b(it)?)\s*", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex WebsitePrefixRegex = new Regex(@"^\[\s*[a-z]+(\.[a-z]+)+\s*\][- ]*", + private static readonly Regex WebsitePrefixRegex = new Regex(@"^\[\s*[a-z]+(\.[a-z]+)+\s*\][- ]*|^www\.[a-z]+\.(?:com|net)[ -]*", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex AirDateRegex = new Regex(@"^(.*?)(?<!\d)((?<airyear>\d{4})[_.-](?<airmonth>[0-1][0-9])[_.-](?<airday>[0-3][0-9])|(?<airmonth>[0-1][0-9])[_.-](?<airday>[0-3][0-9])[_.-](?<airyear>\d{4}))(?!\d)", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - 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|Pre|postbot|xpost))+$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex CleanTorrentSuffixRegex = new Regex(@"\[(?:ettv|rartv|rarbg|cttv)\]$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex CleanQualityBracketsRegex = new Regex(@"\[[a-z0-9 ._-]+\]$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex ReleaseGroupRegex = new Regex(@"-(?<releasegroup>[a-z0-9]+)(?<!WEB-DL|480p|720p|1080p|2160p)(?:\b|[-._ ])", RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -316,20 +354,24 @@ 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); simpleTitle = CleanTorrentSuffixRegex.Replace(simpleTitle, string.Empty); - var airDateMatch = AirDateRegex.Match(simpleTitle); - if (airDateMatch.Success) + simpleTitle = CleanQualityBracketsRegex.Replace(simpleTitle, m => { - simpleTitle = airDateMatch.Groups[1].Value + airDateMatch.Groups["airyear"].Value + "." + airDateMatch.Groups["airmonth"].Value + "." + airDateMatch.Groups["airday"].Value; - } + if (QualityParser.ParseQualityName(m.Value).Quality != Qualities.Quality.Unknown) + { + return string.Empty; + } + + return m.Value; + }); var sixDigitAirDateMatch = SixDigitAirDateRegex.Match(simpleTitle); if (sixDigitAirDateMatch.Success) @@ -355,7 +397,7 @@ namespace NzbDrone.Core.Parser Logger.Trace(regex); try { - var result = ParseMatchCollection(match); + var result = ParseMatchCollection(match, releaseTitle); if (result != null) { @@ -365,13 +407,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()) @@ -435,7 +477,19 @@ namespace NzbDrone.Core.Parser public static string NormalizeEpisodeTitle(string title) { - title = SpecialEpisodeWordRegex.Replace(title, string.Empty); + var match = SpecialEpisodeTitleRegex + .Select(v => v.Match(title)) + .Where(v => v.Success) + .FirstOrDefault(); + + if (match != null) + { + title = match.Groups["episodetitle"].Value; + } + + // Disabled, Until we run into specific testcases for the removal of these words. + //title = SpecialEpisodeWordRegex.Replace(title, string.Empty); + title = PunctuationRegex.Replace(title, " "); title = DuplicateSpacesRegex.Replace(title, " "); @@ -522,7 +576,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 +605,7 @@ namespace NzbDrone.Core.Parser result = new ParsedEpisodeInfo { + ReleaseTitle = releaseTitle, SeasonNumber = seasons.First(), EpisodeNumbers = new int[0], AbsoluteEpisodeNumbers = new int[0] @@ -597,11 +652,26 @@ namespace NzbDrone.Core.Parser if (!episodeCaptures.Any() && !absoluteEpisodeCaptures.Any()) { - //Check to see if this is an "Extras" or "SUBPACK" release, if it is, return NULL - //Todo: Set a "Extras" flag in EpisodeParseResult if we want to download them ever - if (!matchCollection[0].Groups["extras"].Value.IsNullOrWhiteSpace()) return null; + //Check to see if this is an "Extras" or "SUBPACK" release, if it is, set + // IsSeasonExtra so they can be filtered out + if (!matchCollection[0].Groups["extras"].Value.IsNullOrWhiteSpace()) + { + result.IsSeasonExtra = true; + } - result.FullSeason = true; + // Partial season packs will have a seasonpart group so they can be differentiated + // from a full season/single episode release + var seasonPart = matchCollection[0].Groups["seasonpart"].Value; + + if (seasonPart.IsNotNullOrWhiteSpace()) + { + result.SeasonPart = Convert.ToInt32(seasonPart); + result.IsPartialSeason = true; + } + else + { + result.FullSeason = true; + } } } @@ -644,6 +714,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..096d84955 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -1,11 +1,9 @@ -using System.Collections.Generic; -using System.IO; +using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tv; @@ -13,13 +11,12 @@ namespace NzbDrone.Core.Parser { public interface IParsingService { - LocalEpisode GetLocalEpisode(string filename, Series series); - LocalEpisode GetLocalEpisode(string filename, Series series, ParsedEpisodeInfo folderInfo, bool sceneSource); Series GetSeries(string title); 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(ParsedEpisodeInfo parsedEpisodeInfo, string releaseTitle, int tvdbId, int tvRageId, SearchCriteriaBase searchCriteria = null); + ParsedEpisodeInfo ParseSpecialEpisodeTitle(ParsedEpisodeInfo parsedEpisodeInfo, string releaseTitle, Series series); } public class ParsingService : IParsingService @@ -40,60 +37,6 @@ namespace NzbDrone.Core.Parser _logger = logger; } - public LocalEpisode GetLocalEpisode(string filename, Series series) - { - return GetLocalEpisode(filename, series, null, false); - } - - public LocalEpisode GetLocalEpisode(string filename, Series series, ParsedEpisodeInfo folderInfo, bool sceneSource) - { - ParsedEpisodeInfo parsedEpisodeInfo; - - if (folderInfo != null) - { - parsedEpisodeInfo = folderInfo.JsonClone(); - parsedEpisodeInfo.Quality = QualityParser.ParseQuality(Path.GetFileName(filename)); - } - - else - { - parsedEpisodeInfo = Parser.ParsePath(filename); - } - - if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode) - { - var title = Path.GetFileNameWithoutExtension(filename); - var specialEpisodeInfo = ParseSpecialEpisodeTitle(title, series); - - if (specialEpisodeInfo != null) - { - parsedEpisodeInfo = specialEpisodeInfo; - } - } - - if (parsedEpisodeInfo == null) - { - if (MediaFileExtensions.Extensions.Contains(Path.GetExtension(filename))) - { - _logger.Warn("Unable to parse episode info from path {0}", filename); - } - - return null; - } - - var episodes = GetEpisodes(parsedEpisodeInfo, series, sceneSource); - - return new LocalEpisode - { - Series = series, - Quality = parsedEpisodeInfo.Quality, - Episodes = episodes, - Path = filename, - ParsedEpisodeInfo = parsedEpisodeInfo, - ExistingFile = series.Path.IsParentPath(filename) - }; - } - public Series GetSeries(string title) { var parsedEpisodeInfo = Parser.ParseTitle(title); @@ -103,6 +46,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) @@ -129,7 +79,15 @@ namespace NzbDrone.Core.Parser } remoteEpisode.Series = series; - remoteEpisode.Episodes = GetEpisodes(parsedEpisodeInfo, series, true, searchCriteria); + + if (ValidateParsedEpisodeInfo.ValidateForSeriesType(parsedEpisodeInfo, series)) + { + remoteEpisode.Episodes = GetEpisodes(parsedEpisodeInfo, series, true, searchCriteria); + } + else + { + remoteEpisode.Episodes = new List<Episode>(); + } return remoteEpisode; } @@ -153,12 +111,6 @@ namespace NzbDrone.Core.Parser if (parsedEpisodeInfo.IsDaily) { - if (series.SeriesType == SeriesTypes.Standard) - { - _logger.Warn("Found daily-style episode for non-daily series: {0}.", series); - return new List<Episode>(); - } - var episodeInfo = GetDailyEpisode(series, parsedEpisodeInfo.AirDate, searchCriteria); if (episodeInfo != null) @@ -177,29 +129,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(ParsedEpisodeInfo parsedEpisodeInfo, 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(parsedEpisodeInfo, releaseTitle, searchCriteria.Series); } if (tvRageId != 0 && tvRageId == searchCriteria.Series.TvRageId) { - return ParseSpecialEpisodeTitle(title, searchCriteria.Series); + return ParseSpecialEpisodeTitle(parsedEpisodeInfo, 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 +163,48 @@ 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(parsedEpisodeInfo, releaseTitle, series); } - private ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series) + public ParsedEpisodeInfo ParseSpecialEpisodeTitle(ParsedEpisodeInfo parsedEpisodeInfo, string releaseTitle, Series series) { + // SxxE00 episodes are sometimes mapped via TheXEM, don't use episode title parsing in that case. + if (parsedEpisodeInfo != null && parsedEpisodeInfo.IsPossibleSceneSeasonSpecial && series.UseSceneNumbering) + { + if (_episodeService.FindEpisodesBySceneNumbering(series.Id, parsedEpisodeInfo.SeasonNumber, 0).Any()) + { + return parsedEpisodeInfo; + } + } + // 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 +215,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,17 +304,17 @@ 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) { - Episode episode = null; + var episodes = new List<Episode>(); if (parsedEpisodeInfo.Special) { - episode = _episodeService.FindEpisode(series.Id, 0, absoluteEpisodeNumber); + var episode = _episodeService.FindEpisode(series.Id, 0, absoluteEpisodeNumber); + episodes.AddIfNotNull(episode); } - else if (sceneSource) { // Is there a reason why we excluded season 1 from this handling before? @@ -359,31 +322,33 @@ namespace NzbDrone.Core.Parser // If this needs to be reverted tests will need to be added if (sceneSeasonNumber.HasValue) { - var episodes = _episodeService.FindEpisodesBySceneNumbering(series.Id, sceneSeasonNumber.Value, absoluteEpisodeNumber); + episodes = _episodeService.FindEpisodesBySceneNumbering(series.Id, sceneSeasonNumber.Value, absoluteEpisodeNumber); - if (episodes.Count == 1) + if (episodes.Empty()) { - episode = episodes.First(); - } - - if (episode == null) - { - episode = _episodeService.FindEpisode(series.Id, sceneSeasonNumber.Value, absoluteEpisodeNumber); + var episode = _episodeService.FindEpisode(series.Id, sceneSeasonNumber.Value, absoluteEpisodeNumber); + episodes.AddIfNotNull(episode); } } - else { - episode = _episodeService.FindEpisodeBySceneNumbering(series.Id, absoluteEpisodeNumber); + episodes = _episodeService.FindEpisodesBySceneNumbering(series.Id, absoluteEpisodeNumber); + + // Don't allow multiple results without a scene name mapping. + if (episodes.Count > 1) + { + episodes.Clear(); + } } } - if (episode == null) + if (episodes.Empty()) { - episode = _episodeService.FindEpisode(series.Id, absoluteEpisodeNumber); + var episode = _episodeService.FindEpisode(series.Id, absoluteEpisodeNumber); + episodes.AddIfNotNull(episode); } - if (episode != null) + foreach (var episode in episodes) { _logger.Debug("Using absolute episode number {0} for: {1} - TVDB: {2}x{3:00}", absoluteEpisodeNumber, @@ -405,7 +370,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 +440,4 @@ namespace NzbDrone.Core.Parser return result; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Parser/QualityParser.cs b/src/NzbDrone.Core/Parser/QualityParser.cs index 7154cd3fd..982901b65 100644 --- a/src/NzbDrone.Core/Parser/QualityParser.cs +++ b/src/NzbDrone.Core/Parser/QualityParser.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -16,7 +16,7 @@ namespace NzbDrone.Core.Parser private static readonly Regex SourceRegex = new Regex(@"\b(?: (?<bluray>BluRay|Blu-Ray|HDDVD|BD)| - (?<webdl>WEB[-_. ]DL|WEBDL|WebRip|iTunesHD|WebHD|[. ]WEB[. ](?:[xh]26[45]|DD5[. ]1)|\d+0p[. ]WEB[. ])| + (?<webdl>WEB[-_. ]DL|WEBDL|WebRip|AmazonHD|iTunesHD|NetflixU?HD|WebHD|[. ]WEB[. ](?:[xh]26[45]|DD5[. ]1)|\d+0p[. ]WEB[. ]|WEB-DLMux)| (?<hdtv>HDTV)| (?<bdrip>BDRip)| (?<brrip>BRRip)| @@ -40,7 +40,7 @@ namespace NzbDrone.Core.Parser private static readonly Regex RealRegex = new Regex(@"\b(?<real>REAL)\b", RegexOptions.Compiled); - private static readonly Regex ResolutionRegex = new Regex(@"\b(?:(?<R480p>480p|640x480|848x480)|(?<R576p>576p)|(?<R720p>720p|1280x720)|(?<R1080p>1080p|1920x1080)|(?<R2160p>2160p))\b", + private static readonly Regex ResolutionRegex = new Regex(@"\b(?:(?<R480p>480p|640x480|848x480)|(?<R576p>576p)|(?<R720p>720p|1280x720)|(?<R1080p>1080p|1920x1080|1440p)|(?<R2160p>2160p|4k[-_. ]UHD|UHD[-_. ]4k))\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex CodecRegex = new Regex(@"\b(?:(?<x264>x264)|(?<h264>h264)|(?<xvidhd>XvidHD)|(?<xvid>Xvid)|(?<divx>divx))\b", @@ -57,6 +57,29 @@ namespace NzbDrone.Core.Parser Logger.Debug("Trying to parse quality for {0}", name); name = name.Trim(); + + var result = ParseQualityName(name); + + //Based on extension + if (result.Quality == Quality.Unknown && !name.ContainsInvalidPathChars()) + { + try + { + result.Quality = MediaFileExtensions.GetQualityForExtension(Path.GetExtension(name)); + result.QualityDetectionSource = QualityDetectionSource.Extension; + } + catch (ArgumentException) + { + //Swallow exception for cases where string contains illegal + //path characters. + } + } + + return result; + } + + public static QualityModel ParseQualityName(string name) + { var normalizedName = name.Replace('_', ' ').Trim().ToLower(); var result = ParseQualityModifiers(name, normalizedName); @@ -190,6 +213,18 @@ namespace NzbDrone.Core.Parser sourceMatch.Groups["dsr"].Success || sourceMatch.Groups["tvrip"].Success) { + if (resolution == Resolution.R1080p || normalizedName.Contains("1080p")) + { + result.Quality = Quality.HDTV1080p; + return result; + } + + if (resolution == Resolution.R720p || normalizedName.Contains("720p")) + { + result.Quality = Quality.HDTV720p; + return result; + } + if (HighDefPdtvRegex.IsMatch(normalizedName)) { result.Quality = Quality.HDTV720p; @@ -298,21 +333,6 @@ namespace NzbDrone.Core.Parser result.Quality = otherSourceMatch; } - //Based on extension - if (result.Quality == Quality.Unknown && !name.ContainsInvalidPathChars()) - { - try - { - result.Quality = MediaFileExtensions.GetQualityForExtension(Path.GetExtension(name)); - result.QualitySource = QualitySource.Extension; - } - catch (ArgumentException) - { - //Swallow exception for cases where string contains illegal - //path characters. - } - } - return result; } diff --git a/src/NzbDrone.Core/Parser/ValidateParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/ValidateParsedEpisodeInfo.cs new file mode 100644 index 000000000..3f3120678 --- /dev/null +++ b/src/NzbDrone.Core/Parser/ValidateParsedEpisodeInfo.cs @@ -0,0 +1,33 @@ +using NLog; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Parser +{ + public static class ValidateParsedEpisodeInfo + { + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(ValidateParsedEpisodeInfo)); + + public static bool ValidateForSeriesType(ParsedEpisodeInfo parsedEpisodeInfo, Series series, bool warnIfInvalid = true) + { + if (parsedEpisodeInfo.IsDaily && series.SeriesType == SeriesTypes.Standard) + { + var message = $"Found daily-style episode for non-daily series: {series}"; + + if (warnIfInvalid) + { + Logger.Warn(message); + } + else + { + Logger.Debug(message); + } + + return false; + } + + return true; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Delay/DelayProfileService.cs b/src/NzbDrone.Core/Profiles/Delay/DelayProfileService.cs index a367ce4eb..7afffee41 100644 --- a/src/NzbDrone.Core/Profiles/Delay/DelayProfileService.cs +++ b/src/NzbDrone.Core/Profiles/Delay/DelayProfileService.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; namespace NzbDrone.Core.Profiles.Delay @@ -18,20 +20,26 @@ namespace NzbDrone.Core.Profiles.Delay public class DelayProfileService : IDelayProfileService { private readonly IDelayProfileRepository _repo; + private readonly ICached<DelayProfile> _bestForTagsCache; - public DelayProfileService(IDelayProfileRepository repo) + public DelayProfileService(IDelayProfileRepository repo, ICacheManager cacheManager) { _repo = repo; + _bestForTagsCache = cacheManager.GetCache<DelayProfile>(GetType(), "best"); } public DelayProfile Add(DelayProfile profile) { - return _repo.Insert(profile); + var result = _repo.Insert(profile); + _bestForTagsCache.Clear(); + return result; } public DelayProfile Update(DelayProfile profile) { - return _repo.Update(profile); + var result = _repo.Update(profile); + _bestForTagsCache.Clear(); + return result; } public void Delete(int id) @@ -48,6 +56,7 @@ namespace NzbDrone.Core.Profiles.Delay } _repo.UpdateMany(all); + _bestForTagsCache.Clear(); } public List<DelayProfile> All() @@ -67,7 +76,14 @@ namespace NzbDrone.Core.Profiles.Delay public DelayProfile BestForTags(HashSet<int> tagIds) { - return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()) + var key = "-" + tagIds.Select(v => v.ToString()).Join(","); + return _bestForTagsCache.Get(key, () => FetchBestForTags(tagIds), TimeSpan.FromSeconds(30)); + } + + private DelayProfile FetchBestForTags(HashSet<int> tagIds) + { + return _repo.All() + .Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()) .OrderBy(d => d.Order).First(); } } diff --git a/src/NzbDrone.Core/Profiles/ProfileInUseException.cs b/src/NzbDrone.Core/Profiles/ProfileInUseException.cs index d55523d9a..b48488a4d 100644 --- a/src/NzbDrone.Core/Profiles/ProfileInUseException.cs +++ b/src/NzbDrone.Core/Profiles/ProfileInUseException.cs @@ -1,13 +1,13 @@ -using NzbDrone.Common.Exceptions; +using System.Net; +using NzbDrone.Core.Exceptions; namespace NzbDrone.Core.Profiles { - public class ProfileInUseException : NzbDroneException + public class ProfileInUseException : NzbDroneClientException { - public ProfileInUseException(int profileId) - : base("Profile [{0}] is in use.", profileId) + public ProfileInUseException(string name) + : base(HttpStatusCode.BadRequest, "Profile [{0}] is in use.", name) { - } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Profiles/ProfileService.cs b/src/NzbDrone.Core/Profiles/ProfileService.cs index 89c569ff1..b5da50ab4 100644 --- a/src/NzbDrone.Core/Profiles/ProfileService.cs +++ b/src/NzbDrone.Core/Profiles/ProfileService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Core.Lifecycle; @@ -46,7 +46,8 @@ namespace NzbDrone.Core.Profiles { if (_seriesService.GetAllSeries().Any(c => c.ProfileId == id)) { - throw new ProfileInUseException(id); + var profile = _profileRepository.Get(id); + throw new ProfileInUseException(profile.Name); } _profileRepository.Delete(id); @@ -125,4 +126,4 @@ namespace NzbDrone.Core.Profiles Quality.Bluray1080p); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Qualities/Quality.cs b/src/NzbDrone.Core/Qualities/Quality.cs index d41a05d35..fedbd0b34 100644 --- a/src/NzbDrone.Core/Qualities/Quality.cs +++ b/src/NzbDrone.Core/Qualities/Quality.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Datastore; @@ -9,15 +9,19 @@ namespace NzbDrone.Core.Qualities { public int Id { get; set; } public string Name { get; set; } + public QualitySource Source { get; set; } + public int Resolution { get; set; } public Quality() { } - private Quality(int id, string name) + private Quality(int id, string name, QualitySource source, int resolution) { Id = id; Name = name; + Source = source; + Resolution = resolution; } public override string ToString() @@ -55,26 +59,26 @@ namespace NzbDrone.Core.Qualities return !Equals(left, right); } - public static Quality Unknown => new Quality(0, "Unknown"); - public static Quality SDTV => new Quality(1, "SDTV"); - public static Quality DVD => new Quality(2, "DVD"); - public static Quality WEBDL1080p => new Quality(3, "WEBDL-1080p"); - public static Quality HDTV720p => new Quality(4, "HDTV-720p"); - public static Quality WEBDL720p => new Quality(5, "WEBDL-720p"); - public static Quality Bluray720p => new Quality(6, "Bluray-720p"); - public static Quality Bluray1080p => new Quality(7, "Bluray-1080p"); - public static Quality WEBDL480p => new Quality(8, "WEBDL-480p"); - public static Quality HDTV1080p => new Quality(9, "HDTV-1080p"); - public static Quality RAWHD => new Quality(10, "Raw-HD"); - //public static Quality HDTV480p { get { return new Quality(11, "HDTV-480p"); } } - //public static Quality WEBRip480p { get { return new Quality(12, "WEBRip-480p"); } } - //public static Quality Bluray480p { get { return new Quality(13, "Bluray-480p"); } } - //public static Quality WEBRip720p { get { return new Quality(14, "WEBRip-720p"); } } - //public static Quality WEBRip1080p { get { return new Quality(15, "WEBRip-1080p"); } } - public static Quality HDTV2160p => new Quality(16, "HDTV-2160p"); - //public static Quality WEBRip2160p { get { return new Quality(17, "WEBRip-2160p"); } } - public static Quality WEBDL2160p => new Quality(18, "WEBDL-2160p"); - public static Quality Bluray2160p => new Quality(19, "Bluray-2160p"); + public static Quality Unknown => new Quality(0, "Unknown", QualitySource.Unknown, 0); + public static Quality SDTV => new Quality(1, "SDTV", QualitySource.Television, 480); + public static Quality DVD => new Quality(2, "DVD", QualitySource.DVD, 480); + public static Quality WEBDL1080p => new Quality(3, "WEBDL-1080p", QualitySource.Web, 1080); + public static Quality HDTV720p => new Quality(4, "HDTV-720p", QualitySource.Television, 720); + public static Quality WEBDL720p => new Quality(5, "WEBDL-720p", QualitySource.Web, 720); + public static Quality Bluray720p => new Quality(6, "Bluray-720p", QualitySource.Bluray, 720); + public static Quality Bluray1080p => new Quality(7, "Bluray-1080p", QualitySource.Bluray, 1080); + public static Quality WEBDL480p => new Quality(8, "WEBDL-480p", QualitySource.Web, 480); + public static Quality HDTV1080p => new Quality(9, "HDTV-1080p", QualitySource.Television, 1080); + public static Quality RAWHD => new Quality(10, "Raw-HD", QualitySource.TelevisionRaw, 1080); + //public static Quality HDTV480p { get { return new Quality(11, "HDTV-480p", QualitySource.Television, 480); } } + //public static Quality WEBRip480p { get { return new Quality(12, "WEBRip-480p", QualitySource.WebRip, 480); } } + //public static Quality Bluray480p { get { return new Quality(13, "Bluray-480p", QualitySource.Bluray, 480); } } + //public static Quality WEBRip720p { get { return new Quality(14, "WEBRip-720p", QualitySource.WebRip, 720); } } + //public static Quality WEBRip1080p { get { return new Quality(15, "WEBRip-1080p", QualitySource.WebRip, 1080); } } + public static Quality HDTV2160p => new Quality(16, "HDTV-2160p", QualitySource.Television, 2160); + //public static Quality WEBRip2160p { get { return new Quality(17, "WEBRip-2160p", QualitySource.WebRip, 2160); } } + public static Quality WEBDL2160p => new Quality(18, "WEBDL-2160p", QualitySource.Web, 2160); + public static Quality Bluray2160p => new Quality(19, "Bluray-2160p", QualitySource.Bluray, 2160); static Quality() { @@ -148,5 +152,10 @@ namespace NzbDrone.Core.Qualities { return quality.Id; } + + public static Quality FindBySourceAndResolution(QualitySource source, int resolution) + { + return All.SingleOrDefault(q => q.Source == source && q.Resolution == resolution); + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Qualities/QualityDetectionSource.cs b/src/NzbDrone.Core/Qualities/QualityDetectionSource.cs new file mode 100644 index 000000000..3f7695214 --- /dev/null +++ b/src/NzbDrone.Core/Qualities/QualityDetectionSource.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Qualities +{ + public enum QualityDetectionSource + { + Name, + Extension, + MediaInfo + } +} diff --git a/src/NzbDrone.Core/Qualities/QualityModel.cs b/src/NzbDrone.Core/Qualities/QualityModel.cs index a483d22c2..b4954207a 100644 --- a/src/NzbDrone.Core/Qualities/QualityModel.cs +++ b/src/NzbDrone.Core/Qualities/QualityModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using Newtonsoft.Json; using NzbDrone.Core.Datastore; @@ -10,8 +10,8 @@ namespace NzbDrone.Core.Qualities public Revision Revision { get; set; } [JsonIgnore] - public QualitySource QualitySource { get; set; } - + public QualityDetectionSource QualityDetectionSource { get; set; } + public QualityModel() : this(Quality.Unknown, new Revision()) { diff --git a/src/NzbDrone.Core/Qualities/QualitySource.cs b/src/NzbDrone.Core/Qualities/QualitySource.cs index 5c0c2c81f..258de8813 100644 --- a/src/NzbDrone.Core/Qualities/QualitySource.cs +++ b/src/NzbDrone.Core/Qualities/QualitySource.cs @@ -1,9 +1,13 @@ -namespace NzbDrone.Core.Qualities +namespace NzbDrone.Core.Qualities { public enum QualitySource { - Name, - Extension, - MediaInfo + Unknown, + Television, + TelevisionRaw, + Web, + WebRip, + DVD, + Bluray } } diff --git a/src/NzbDrone.Core/Restrictions/PerlRegexFactory.cs b/src/NzbDrone.Core/Restrictions/PerlRegexFactory.cs new file mode 100644 index 000000000..1617bbdcf --- /dev/null +++ b/src/NzbDrone.Core/Restrictions/PerlRegexFactory.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Restrictions +{ + public static class PerlRegexFactory + { + private static Regex _perlRegexFormat = new Regex(@"/(?<pattern>.*)/(?<modifiers>[a-z]*)", RegexOptions.Compiled); + + public static bool TryCreateRegex(string pattern, out Regex regex) + { + var match = _perlRegexFormat.Match(pattern); + + if (!match.Success) + { + regex = null; + return false; + } + + regex = CreateRegex(match.Groups["pattern"].Value, match.Groups["modifiers"].Value); + return true; + } + + public static Regex CreateRegex(string pattern, string modifiers) + { + var options = GetOptions(modifiers); + + // For now we simply expect the pattern to be .net compliant. We should probably check and reject perl-specific constructs. + return new Regex(pattern, options | RegexOptions.Compiled); + } + + private static RegexOptions GetOptions(string modifiers) + { + var options = RegexOptions.None; + + foreach (var modifier in modifiers) + { + switch (modifier) + { + case 'm': + options |= RegexOptions.Multiline; + break; + + case 's': + options |= RegexOptions.Singleline; + break; + + case 'i': + options |= RegexOptions.IgnoreCase; + break; + + case 'x': + options |= RegexOptions.IgnorePatternWhitespace; + break; + + case 'n': + options |= RegexOptions.ExplicitCapture; + break; + + default: + throw new ArgumentException("Unknown or unsupported perl regex modifier: " + modifier); + } + } + + return options; + } + } +} diff --git a/src/NzbDrone.Core/Restrictions/TermMatcher.cs b/src/NzbDrone.Core/Restrictions/TermMatcher.cs new file mode 100644 index 000000000..af0c3d1f5 --- /dev/null +++ b/src/NzbDrone.Core/Restrictions/TermMatcher.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using NzbDrone.Common.Cache; + +namespace NzbDrone.Core.Restrictions +{ + public interface ITermMatcher + { + bool IsMatch(string term, string value); + } + + public class TermMatcher : ITermMatcher + { + private ICached<Predicate<string>> _matcherCache; + + public TermMatcher(ICacheManager cacheManager) + { + _matcherCache = cacheManager.GetCache<Predicate<string>>(GetType()); + } + + public bool IsMatch(string term, string value) + { + return GetMatcher(term)(value); + } + + public Predicate<string> GetMatcher(string term) + { + return _matcherCache.Get(term, () => CreateMatcherInternal(term), TimeSpan.FromHours(24)); + } + + private Predicate<string> CreateMatcherInternal(string term) + { + Regex regex; + if (PerlRegexFactory.TryCreateRegex(term, out regex)) + { + return regex.IsMatch; + } + else + { + return new CaseInsensitiveTermMatcher(term).IsMatch; + + } + } + + private sealed class CaseInsensitiveTermMatcher + { + private readonly string _term; + + public CaseInsensitiveTermMatcher(string term) + { + _term = term.ToLowerInvariant(); + } + + public bool IsMatch(string value) + { + return value.ToLowerInvariant().Contains(_term); + } + } + } +} diff --git a/src/NzbDrone.Core/RootFolders/RootFolder.cs b/src/NzbDrone.Core/RootFolders/RootFolder.cs index 823265323..f32716b52 100644 --- a/src/NzbDrone.Core/RootFolders/RootFolder.cs +++ b/src/NzbDrone.Core/RootFolders/RootFolder.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Core.Datastore; @@ -9,7 +9,8 @@ namespace NzbDrone.Core.RootFolders public string Path { get; set; } public long? FreeSpace { get; set; } + public long? TotalSpace { get; set; } public List<UnmappedFolder> UnmappedFolders { get; set; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/RootFolders/RootFolderService.cs b/src/NzbDrone.Core/RootFolders/RootFolderService.cs index fcccb005c..99d1499f4 100644 --- a/src/NzbDrone.Core/RootFolders/RootFolderService.cs +++ b/src/NzbDrone.Core/RootFolders/RootFolderService.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using System; using System.Collections.Generic; using System.IO; @@ -73,6 +73,7 @@ namespace NzbDrone.Core.RootFolders if (folder.Path.IsPathValid() && _diskProvider.FolderExists(folder.Path)) { folder.FreeSpace = _diskProvider.GetAvailableSpace(folder.Path); + folder.TotalSpace = _diskProvider.GetTotalSize(folder.Path); folder.UnmappedFolders = GetUnmappedFolders(folder.Path); } } @@ -80,7 +81,6 @@ namespace NzbDrone.Core.RootFolders catch (Exception ex) { _logger.Error(ex, "Unable to get free space and unmapped folders for root folder {0}", folder.Path); - folder.FreeSpace = 0; folder.UnmappedFolders = new List<UnmappedFolder>(); } }); @@ -120,7 +120,9 @@ namespace NzbDrone.Core.RootFolders _rootFolderRepository.Insert(rootFolder); rootFolder.FreeSpace = _diskProvider.GetAvailableSpace(rootFolder.Path); + rootFolder.TotalSpace = _diskProvider.GetTotalSize(rootFolder.Path); rootFolder.UnmappedFolders = GetUnmappedFolders(rootFolder.Path); + return rootFolder; } @@ -167,8 +169,9 @@ namespace NzbDrone.Core.RootFolders { var rootFolder = _rootFolderRepository.Get(id); rootFolder.FreeSpace = _diskProvider.GetAvailableSpace(rootFolder.Path); + rootFolder.TotalSpace = _diskProvider.GetTotalSize(rootFolder.Path); rootFolder.UnmappedFolders = GetUnmappedFolders(rootFolder.Path); return rootFolder; } } -} \ 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/EscalationBackOff.cs b/src/NzbDrone.Core/ThingiProvider/Status/EscalationBackOff.cs new file mode 100644 index 000000000..304613d58 --- /dev/null +++ b/src/NzbDrone.Core/ThingiProvider/Status/EscalationBackOff.cs @@ -0,0 +1,18 @@ +namespace NzbDrone.Core.ThingiProvider.Status +{ + public static class EscalationBackOff + { + public static readonly int[] Periods = + { + 0, + 5 * 60, + 15 * 60, + 30 * 60, + 60 * 60, + 3 * 60 * 60, + 6 * 60 * 60, + 12 * 60 * 60, + 24 * 60 * 60 + }; + } +} 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..500559072 --- /dev/null +++ b/src/NzbDrone.Core/ThingiProvider/Status/ProviderStatusServiceBase.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Core.Messaging.Events; +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() + { + protected readonly object _syncRoot = new object(); + + protected readonly IProviderStatusRepository<TModel> _providerStatusRepository; + protected readonly IEventAggregator _eventAggregator; + protected readonly Logger _logger; + + protected int MaximumEscalationLevel { get; set; } = EscalationBackOff.Periods.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(EscalationBackOff.Periods[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..c680116c6 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; } } @@ -95,7 +83,7 @@ namespace NzbDrone.Core.Tv _episodeService.UpdateEpisodes(episodes); } - _seriesService.UpdateSeries(series); + _seriesService.UpdateSeries(series, false); } private void ToggleEpisodesMonitoredState(IEnumerable<Episode> episodes, bool monitored) diff --git a/src/NzbDrone.Core/Tv/EpisodeRepository.cs b/src/NzbDrone.Core/Tv/EpisodeRepository.cs index 5a1f413ad..a0602e307 100644 --- a/src/NzbDrone.Core/Tv/EpisodeRepository.cs +++ b/src/NzbDrone.Core/Tv/EpisodeRepository.cs @@ -25,7 +25,7 @@ namespace NzbDrone.Core.Tv PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec, bool includeSpecials); PagingSpec<Episode> EpisodesWhereCutoffUnmet(PagingSpec<Episode> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff, bool includeSpecials); List<Episode> FindEpisodesBySceneNumbering(int seriesId, int seasonNumber, int episodeNumber); - Episode FindEpisodeBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber); + List<Episode> FindEpisodesBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber); List<Episode> EpisodesBetweenDates(DateTime startDate, DateTime endDate, bool includeUnmonitored); void SetMonitoredFlat(Episode episode, bool monitored); void SetMonitoredBySeason(int seriesId, int seasonNumber, bool monitored); @@ -134,21 +134,15 @@ namespace NzbDrone.Core.Tv { return Query.Where(s => s.SeriesId == seriesId) .AndWhere(s => s.SceneSeasonNumber == seasonNumber) - .AndWhere(s => s.SceneEpisodeNumber == episodeNumber); + .AndWhere(s => s.SceneEpisodeNumber == episodeNumber) + .ToList(); } - public Episode FindEpisodeBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber) + public List<Episode> FindEpisodesBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber) { - var episodes = Query.Where(s => s.SeriesId == seriesId) + return Query.Where(s => s.SeriesId == seriesId) .AndWhere(s => s.SceneAbsoluteEpisodeNumber == sceneAbsoluteEpisodeNumber) .ToList(); - - if (episodes.Empty() || episodes.Count > 1) - { - return null; - } - - return episodes.Single(); } public List<Episode> EpisodesBetweenDates(DateTime startDate, DateTime endDate, bool includeUnmonitored) diff --git a/src/NzbDrone.Core/Tv/EpisodeService.cs b/src/NzbDrone.Core/Tv/EpisodeService.cs index 32a46ec45..acb756bd8 100644 --- a/src/NzbDrone.Core/Tv/EpisodeService.cs +++ b/src/NzbDrone.Core/Tv/EpisodeService.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Core.Tv Episode FindEpisode(int seriesId, int absoluteEpisodeNumber); Episode FindEpisodeByTitle(int seriesId, int seasonNumber, string releaseTitle); List<Episode> FindEpisodesBySceneNumbering(int seriesId, int seasonNumber, int episodeNumber); - Episode FindEpisodeBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber); + List<Episode> FindEpisodesBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber); Episode GetEpisode(int seriesId, string date); Episode FindEpisode(int seriesId, string date); List<Episode> GetEpisodeBySeries(int seriesId); @@ -78,9 +78,9 @@ namespace NzbDrone.Core.Tv return _episodeRepository.FindEpisodesBySceneNumbering(seriesId, seasonNumber, episodeNumber); } - public Episode FindEpisodeBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber) + public List<Episode> FindEpisodesBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber) { - return _episodeRepository.FindEpisodeBySceneNumbering(seriesId, sceneAbsoluteEpisodeNumber); + return _episodeRepository.FindEpisodesBySceneNumbering(seriesId, sceneAbsoluteEpisodeNumber); } public Episode GetEpisode(int seriesId, string date) @@ -102,11 +102,11 @@ namespace NzbDrone.Core.Tv { return _episodeRepository.GetEpisodes(seriesId, seasonNumber); } - - public Episode FindEpisodeByTitle(int seriesId, int seasonNumber, string releaseTitle) + + public Episode FindEpisodeByTitle(int seriesId, int seasonNumber, string releaseTitle) { // TODO: can replace this search mechanism with something smarter/faster/better - var normalizedReleaseTitle = Parser.Parser.NormalizeEpisodeTitle(releaseTitle).Replace(".", " "); + var normalizedReleaseTitle = Parser.Parser.NormalizeEpisodeTitle(releaseTitle); var episodes = _episodeRepository.GetEpisodes(seriesId, seasonNumber); var matches = episodes.Select( @@ -222,4 +222,4 @@ namespace NzbDrone.Core.Tv } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs index f177b5857..a7f42f554 100644 --- a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs +++ b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -144,6 +144,18 @@ namespace NzbDrone.Core.Tv return seasons; } + private void RescanSeries(Series series) + { + try + { + _diskScanService.Scan(series); + } + catch (Exception e) + { + _logger.Error(e, "Couldn't rescan series {0}", series); + } + } + public void Execute(RefreshSeriesCommand message) { _eventAggregator.PublishEvent(new SeriesRefreshStartingEvent(message.Trigger == CommandTrigger.Manual)); @@ -151,7 +163,17 @@ namespace NzbDrone.Core.Tv if (message.SeriesId.HasValue) { var series = _seriesService.GetSeries(message.SeriesId.Value); - RefreshSeriesInfo(series); + + try + { + RefreshSeriesInfo(series); + } + catch (Exception e) + { + _logger.Error(e, "Couldn't refresh info for {0}", series); + RescanSeries(series); + throw; + } } else { @@ -168,20 +190,14 @@ namespace NzbDrone.Core.Tv catch (Exception e) { _logger.Error(e, "Couldn't refresh info for {0}", series); + RescanSeries(series); } } else { - try - { - _logger.Info("Skipping refresh of series: {0}", series.Title); - _diskScanService.Scan(series); - } - catch (Exception e) - { - _logger.Error(e, "Couldn't rescan series {0}", series); - } + _logger.Info("Skipping refresh of series: {0}", series.Title); + RescanSeries(series); } } } diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index f4db2b0a4..79166d843 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -1,11 +1,8 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; using NLog; -using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; -using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser; @@ -25,7 +22,7 @@ namespace NzbDrone.Core.Tv Series FindByTitleInexact(string title); void DeleteSeries(int seriesId, bool deleteFiles); List<Series> GetAllSeries(); - Series UpdateSeries(Series series); + Series UpdateSeries(Series series, bool updateEpisodesToMatchSeason = true); List<Series> UpdateSeries(List<Series> series); bool SeriesPathExists(string folder); void RemoveAddOptions(Series series); @@ -35,21 +32,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 +79,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 +94,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), @@ -154,7 +141,9 @@ namespace NzbDrone.Core.Tv return _seriesRepository.All().ToList(); } - public Series UpdateSeries(Series series) + // updateEpisodesToMatchSeason is an override for EpisodeMonitoredService to use so a change via Season pass doesn't get nuked by the seasons loop. + // TODO: Remove when seasons are split from series (or we come up with a better way to address this) + public Series UpdateSeries(Series series, bool updateEpisodesToMatchSeason = true) { var storedSeries = GetSeries(series.Id); @@ -162,7 +151,7 @@ namespace NzbDrone.Core.Tv { var storedSeason = storedSeries.Seasons.SingleOrDefault(s => s.SeasonNumber == season.SeasonNumber); - if (storedSeason != null && season.Monitored != storedSeason.Monitored) + if (storedSeason != null && season.Monitored != storedSeason.Monitored && updateEpisodesToMatchSeason) { _episodeService.SetEpisodeMonitoredBySeason(series.Id, season.SeasonNumber, season.Monitored); } @@ -192,7 +181,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/SeriesTitleNormalizer.cs b/src/NzbDrone.Core/Tv/SeriesTitleNormalizer.cs index 9fc2c5933..ba10e8d83 100644 --- a/src/NzbDrone.Core/Tv/SeriesTitleNormalizer.cs +++ b/src/NzbDrone.Core/Tv/SeriesTitleNormalizer.cs @@ -7,8 +7,8 @@ namespace NzbDrone.Core.Tv private readonly static Dictionary<int, string> PreComputedTitles = new Dictionary<int, string> { { 281588, "a to z" }, - { 266757, "ad trials triumph early church" }, - { 289260, "ad bible continues"} + { 289260, "ad bible continues"}, + { 328534, "ap bio"} }; public static string Normalize(string title, int tvdbId) 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/Paths/SeriesAncestorValidator.cs b/src/NzbDrone.Core/Validation/Paths/SeriesAncestorValidator.cs index c91560873..850118a0d 100644 --- a/src/NzbDrone.Core/Validation/Paths/SeriesAncestorValidator.cs +++ b/src/NzbDrone.Core/Validation/Paths/SeriesAncestorValidator.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using FluentValidation.Validators; using NzbDrone.Common.Extensions; using NzbDrone.Core.Tv; @@ -10,7 +10,7 @@ namespace NzbDrone.Core.Validation.Paths private readonly ISeriesService _seriesService; public SeriesAncestorValidator(ISeriesService seriesService) - : base("Path is an ancestor of an existing path") + : base("Path is an ancestor of an existing series") { _seriesService = seriesService; } @@ -22,4 +22,4 @@ namespace NzbDrone.Core.Validation.Paths return !_seriesService.GetAllSeries().Any(s => context.PropertyValue.ToString().IsParentPath(s.Path)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Validation/Paths/SystemFolderValidator.cs b/src/NzbDrone.Core/Validation/Paths/SystemFolderValidator.cs new file mode 100644 index 000000000..ad321f87a --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/SystemFolderValidator.cs @@ -0,0 +1,91 @@ +using System; +using FluentValidation.Validators; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Validation.Paths +{ + public class SystemFolderValidator : PropertyValidator + { + public SystemFolderValidator() + : base("Is {relationship} system folder {systemFolder}") + { + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var folder = context.PropertyValue.ToString(); + + if (OsInfo.IsWindows) + { + var windowsFolder = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + context.MessageFormatter.AppendArgument("systemFolder", windowsFolder); + + if (windowsFolder.PathEquals(folder)) + { + context.MessageFormatter.AppendArgument("relationship", "set to"); + + return false; + } + + if (windowsFolder.IsParentPath(folder)) + { + context.MessageFormatter.AppendArgument("relationship", "child of"); + + return false; + } + } + else if (OsInfo.IsOsx) + { + var systemFolder = "/System"; + context.MessageFormatter.AppendArgument("systemFolder", systemFolder); + + if (systemFolder.PathEquals(folder)) + { + context.MessageFormatter.AppendArgument("relationship", "child of"); + + return false; + } + + if (systemFolder.IsParentPath(folder)) + { + context.MessageFormatter.AppendArgument("relationship", "child of"); + + return false; + } + } + else + { + var folders = new[] + { + "/bin", + "/boot", + "/lib", + "/sbin", + "/proc" + }; + + foreach (var f in folders) + { + context.MessageFormatter.AppendArgument("systemFolder", f); + + if (f.PathEquals(folder)) + { + context.MessageFormatter.AppendArgument("relationship", "child of"); + + return false; + } + + if (f.IsParentPath(folder)) + { + context.MessageFormatter.AppendArgument("relationship", "child of"); + + return false; + } + } + } + + return true; + } + } +} 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.Core/packages.config b/src/NzbDrone.Core/packages.config index eb4dc5c17..fffed8fac 100644 --- a/src/NzbDrone.Core/packages.config +++ b/src/NzbDrone.Core/packages.config @@ -5,7 +5,7 @@ <package id="FluentValidation" version="6.2.1.0" targetFramework="net40" /> <package id="ImageResizer" version="3.4.3" targetFramework="net40" /> <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net40" /> - <package id="NLog" version="4.4.3" targetFramework="net40" /> + <package id="NLog" version="4.5.3" targetFramework="net40" /> <package id="OAuth" version="1.0.3" targetFramework="net40" /> <package id="Prowlin" version="0.9.4456.26422" targetFramework="net40" /> <package id="RestSharp" version="105.2.3" targetFramework="net40" /> diff --git a/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs b/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs index fd483479b..35c2dee83 100644 --- a/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs +++ b/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs @@ -32,7 +32,7 @@ namespace NzbDrone.Host.AccessControl } private List<UrlAcl> InternalUrls { get; } - private List<UrlAcl> RegisteredUrls { get; } + private List<UrlAcl> RegisteredUrls { get; set; } private static readonly Regex UrlAclRegex = new Regex(@"(?<scheme>https?)\:\/\/(?<address>.+?)\:(?<port>\d+)/(?<urlbase>.+)?", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -49,11 +49,16 @@ namespace NzbDrone.Host.AccessControl _logger = logger; InternalUrls = new List<UrlAcl>(); - RegisteredUrls = GetRegisteredUrls(); + RegisteredUrls = new List<UrlAcl>(); } public void ConfigureUrls() { + if (RegisteredUrls.Empty()) + { + GetRegisteredUrls(); + } + var localHostHttpUrls = BuildUrlAcls("http", "localhost", _configFileProvider.Port); var interfaceHttpUrls = BuildUrlAcls("http", _configFileProvider.BindAddress, _configFileProvider.Port); @@ -128,19 +133,24 @@ namespace NzbDrone.Host.AccessControl c.UrlBase == urlAcl.UrlBase); } - private List<UrlAcl> GetRegisteredUrls() + private void GetRegisteredUrls() { if (OsInfo.IsNotWindows) { - return new List<UrlAcl>(); + return; + } + + if (RegisteredUrls.Any()) + { + return; } var arguments = string.Format("http show urlacl"); var output = _netshProvider.Run(arguments); - if (output == null || !output.Standard.Any()) return new List<UrlAcl>(); + if (output == null || !output.Standard.Any()) return; - return output.Standard.Select(line => + RegisteredUrls = output.Standard.Select(line => { var match = UrlAclRegex.Match(line.Content); diff --git a/src/NzbDrone.Host/ApplicationServer.cs b/src/NzbDrone.Host/ApplicationServer.cs index 29d56304e..fbd0f1480 100644 --- a/src/NzbDrone.Host/ApplicationServer.cs +++ b/src/NzbDrone.Host/ApplicationServer.cs @@ -1,8 +1,10 @@ -using System; +using System; using System.ServiceProcess; using NLog; +using NzbDrone.Common.Composition; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Datastore; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Events; using NzbDrone.Host.Owin; @@ -22,6 +24,7 @@ namespace NzbDrone.Host private readonly IHostController _hostController; private readonly IStartupContext _startupContext; private readonly IBrowserService _browserService; + private readonly IContainer _container; private readonly Logger _logger; public NzbDroneServiceFactory(IConfigFileProvider configFileProvider, @@ -29,6 +32,7 @@ namespace NzbDrone.Host IRuntimeInfo runtimeInfo, IStartupContext startupContext, IBrowserService browserService, + IContainer container, Logger logger) { _configFileProvider = configFileProvider; @@ -36,6 +40,7 @@ namespace NzbDrone.Host _runtimeInfo = runtimeInfo; _startupContext = startupContext; _browserService = browserService; + _container = container; _logger = logger; } @@ -52,6 +57,7 @@ namespace NzbDrone.Host } _runtimeInfo.IsExiting = false; + DbFactory.RegisterDatabase(_container); _hostController.StartServer(); if (!_startupContext.Flags.Contains(StartupContext.NO_BROWSER) @@ -59,6 +65,8 @@ namespace NzbDrone.Host { _browserService.LaunchWebUI(); } + + _container.Resolve<IEventAggregator>().PublishEvent(new ApplicationStartedEvent()); } protected override void OnStop() @@ -93,4 +101,4 @@ namespace NzbDrone.Host } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Host/Bootstrap.cs b/src/NzbDrone.Host/Bootstrap.cs index 24a151eeb..b6aa700a6 100644 --- a/src/NzbDrone.Host/Bootstrap.cs +++ b/src/NzbDrone.Host/Bootstrap.cs @@ -1,13 +1,14 @@ -using System; +using System; using System.Reflection; 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.Datastore; +using NzbDrone.Core.Configuration; using NzbDrone.Core.Instrumentation; namespace NzbDrone.Host @@ -49,9 +50,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; } } @@ -70,7 +75,6 @@ namespace NzbDrone.Host EnsureSingleInstance(applicationModes == ApplicationModes.Service, startupContext); } - DbFactory.RegisterDatabase(_container); _container.Resolve<Router>().Route(applicationModes); } @@ -88,11 +92,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/NzbDrone.Host.csproj b/src/NzbDrone.Host/NzbDrone.Host.csproj index 5162772d2..dfab01e41 100644 --- a/src/NzbDrone.Host/NzbDrone.Host.csproj +++ b/src/NzbDrone.Host/NzbDrone.Host.csproj @@ -57,6 +57,7 @@ <RunPostBuildEvent>OnOutputUpdated</RunPostBuildEvent> </PropertyGroup> <ItemGroup> + <Reference Include="Microsoft.CSharp" /> <Reference Include="Microsoft.Owin, Version=2.1.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL"> <SpecificVersion>False</SpecificVersion> <HintPath>..\packages\Microsoft.Owin.2.1.0\lib\net40\Microsoft.Owin.dll</HintPath> @@ -82,10 +83,14 @@ <Private>True</Private> </Reference> <Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL"> - <HintPath>..\packages\NLog.4.4.3\lib\net40\NLog.dll</HintPath> + <HintPath>..\packages\NLog.4.5.3\lib\net40-client\NLog.dll</HintPath> </Reference> <Reference Include="System" /> + <Reference Include="System.Configuration" /> <Reference Include="System.Core" /> + <Reference Include="System.Data" /> + <Reference Include="System.Runtime.Serialization" /> + <Reference Include="System.ServiceModel" /> <Reference Include="System.ServiceProcess" /> <Reference Include="Interop.NetFwTypeLib"> <HintPath>..\Libraries\Interop.NetFwTypeLib.dll</HintPath> @@ -94,6 +99,8 @@ <Reference Include="Owin"> <HintPath>..\packages\Owin.1.0\lib\net40\Owin.dll</HintPath> </Reference> + <Reference Include="System.Transactions" /> + <Reference Include="System.Xml" /> </ItemGroup> <ItemGroup> <Compile Include="..\NzbDrone.Common\Properties\SharedAssemblyInfo.cs"> diff --git a/src/NzbDrone.Host/Router.cs b/src/NzbDrone.Host/Router.cs index 72d1c8f67..e15c54713 100644 --- a/src/NzbDrone.Host/Router.cs +++ b/src/NzbDrone.Host/Router.cs @@ -1,5 +1,6 @@ -using NLog; +using NLog; using NzbDrone.Common; +using NzbDrone.Common.EnvironmentInfo; namespace NzbDrone.Host { @@ -8,14 +9,19 @@ namespace NzbDrone.Host private readonly INzbDroneServiceFactory _nzbDroneServiceFactory; private readonly IServiceProvider _serviceProvider; private readonly IConsoleService _consoleService; + private readonly IRuntimeInfo _runtimeInfo; private readonly Logger _logger; - public Router(INzbDroneServiceFactory nzbDroneServiceFactory, IServiceProvider serviceProvider, - IConsoleService consoleService, Logger logger) + public Router(INzbDroneServiceFactory nzbDroneServiceFactory, + IServiceProvider serviceProvider, + IConsoleService consoleService, + IRuntimeInfo runtimeInfo, + Logger logger) { _nzbDroneServiceFactory = nzbDroneServiceFactory; _serviceProvider = serviceProvider; _consoleService = consoleService; + _runtimeInfo = runtimeInfo; _logger = logger; } @@ -28,14 +34,18 @@ namespace NzbDrone.Host case ApplicationModes.Service: { _logger.Debug("Service selected"); + _serviceProvider.Run(_nzbDroneServiceFactory.Build()); + break; } - + case ApplicationModes.Interactive: { - _logger.Debug("Console selected"); + _logger.Debug(_runtimeInfo.IsWindowsTray ? "Tray selected" : "Console selected"); + _nzbDroneServiceFactory.Start(); + break; } case ApplicationModes.InstallService: 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.Host/packages.config b/src/NzbDrone.Host/packages.config index cd7fd0969..9bc2cc789 100644 --- a/src/NzbDrone.Host/packages.config +++ b/src/NzbDrone.Host/packages.config @@ -6,6 +6,6 @@ <package id="Nancy" version="1.4.3" targetFramework="net40" /> <package id="Nancy.Owin" version="1.4.1" targetFramework="net40" /> <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net40" /> - <package id="NLog" version="4.4.3" targetFramework="net40" /> + <package id="NLog" version="4.5.3" targetFramework="net40" /> <package id="Owin" version="1.0" targetFramework="net40" /> </packages> \ 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/ApiTests/WantedFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/WantedTests/CutoffUnmetFixture.cs similarity index 53% rename from src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs rename to src/NzbDrone.Integration.Test/ApiTests/WantedTests/CutoffUnmetFixture.cs index 01e5df8e5..72b8700d5 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/WantedTests/CutoffUnmetFixture.cs @@ -3,42 +3,11 @@ using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Qualities; -namespace NzbDrone.Integration.Test.ApiTests +namespace NzbDrone.Integration.Test.ApiTests.WantedTests { [TestFixture] - public class WantedFixture : IntegrationTest + public class CutoffUnmetFixture : IntegrationTest { - [Test, Order(0)] - public void missing_should_be_empty() - { - EnsureNoSeries(266189, "The Blacklist"); - - var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); - - result.Records.Should().BeEmpty(); - } - - [Test, Order(1)] - public void missing_should_have_monitored_items() - { - EnsureSeries(266189, "The Blacklist", true); - - var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); - - result.Records.Should().NotBeEmpty(); - } - - [Test, Order(1)] - public void missing_should_have_series() - { - EnsureSeries(266189, "The Blacklist", true); - - var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); - - result.Records.First().Series.Should().NotBeNull(); - result.Records.First().Series.Title.Should().Be("The Blacklist"); - } - [Test, Order(1)] public void cutoff_should_have_monitored_items() { @@ -51,16 +20,6 @@ namespace NzbDrone.Integration.Test.ApiTests result.Records.Should().NotBeEmpty(); } - [Test, Order(1)] - public void missing_should_not_have_unmonitored_items() - { - EnsureSeries(266189, "The Blacklist", false); - - var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); - - result.Records.Should().BeEmpty(); - } - [Test, Order(1)] public void cutoff_should_not_have_unmonitored_items() { @@ -86,16 +45,6 @@ namespace NzbDrone.Integration.Test.ApiTests result.Records.First().Series.Title.Should().Be("The Blacklist"); } - [Test, Order(2)] - public void missing_should_have_unmonitored_items() - { - EnsureSeries(266189, "The Blacklist", false); - - var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc", "monitored", "false"); - - result.Records.Should().NotBeEmpty(); - } - [Test, Order(2)] public void cutoff_should_have_unmonitored_items() { diff --git a/src/NzbDrone.Integration.Test/ApiTests/WantedTests/MissingFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/WantedTests/MissingFixture.cs new file mode 100644 index 000000000..9a75b5357 --- /dev/null +++ b/src/NzbDrone.Integration.Test/ApiTests/WantedTests/MissingFixture.cs @@ -0,0 +1,61 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; + +namespace NzbDrone.Integration.Test.ApiTests.WantedTests +{ + [TestFixture] + public class MissingFixture : IntegrationTest + { + [Test, Order(0)] + public void missing_should_be_empty() + { + EnsureNoSeries(266189, "The Blacklist"); + + var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); + + result.Records.Should().BeEmpty(); + } + + [Test, Order(1)] + public void missing_should_have_monitored_items() + { + EnsureSeries(266189, "The Blacklist", true); + + var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); + + result.Records.Should().NotBeEmpty(); + } + + [Test, Order(1)] + public void missing_should_have_series() + { + EnsureSeries(266189, "The Blacklist", true); + + var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); + + result.Records.First().Series.Should().NotBeNull(); + result.Records.First().Series.Title.Should().Be("The Blacklist"); + } + + [Test, Order(1)] + public void missing_should_not_have_unmonitored_items() + { + EnsureSeries(266189, "The Blacklist", false); + + var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); + + result.Records.Should().BeEmpty(); + } + + [Test, Order(2)] + public void missing_should_have_unmonitored_items() + { + EnsureSeries(266189, "The Blacklist", false); + + var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc", "monitored", "false"); + + result.Records.Should().NotBeEmpty(); + } + } +} 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/CorsFixture.cs b/src/NzbDrone.Integration.Test/CorsFixture.cs index 2d9d8ac4f..a37936a2a 100644 --- a/src/NzbDrone.Integration.Test/CorsFixture.cs +++ b/src/NzbDrone.Integration.Test/CorsFixture.cs @@ -8,30 +8,37 @@ namespace NzbDrone.Integration.Test [TestFixture] public class CorsFixture : IntegrationTest { - private RestRequest BuildRequest() + private RestRequest BuildGet(string route = "series") { - var request = new RestRequest("series"); + var request = new RestRequest(route, Method.GET); request.AddHeader(AccessControlHeaders.RequestMethod, "POST"); return request; } + private RestRequest BuildOptions(string route = "series") + { + var request = new RestRequest(route, Method.OPTIONS); + + return request; + } + [Test] public void should_not_have_allow_headers_in_response_when_not_included_in_the_request() { - var request = BuildRequest(); - var response = RestClient.Get(request); - + var request = BuildOptions(); + var response = RestClient.Execute(request); + response.Headers.Should().NotContain(h => h.Name == AccessControlHeaders.AllowHeaders); } [Test] public void should_have_allow_headers_in_response_when_included_in_the_request() { - var request = BuildRequest(); + var request = BuildOptions(); request.AddHeader(AccessControlHeaders.RequestHeaders, "X-Test"); - var response = RestClient.Get(request); + var response = RestClient.Execute(request); response.Headers.Should().Contain(h => h.Name == AccessControlHeaders.AllowHeaders); } @@ -39,8 +46,8 @@ namespace NzbDrone.Integration.Test [Test] public void should_have_allow_origin_in_response() { - var request = BuildRequest(); - var response = RestClient.Get(request); + var request = BuildOptions(); + var response = RestClient.Execute(request); response.Headers.Should().Contain(h => h.Name == AccessControlHeaders.AllowOrigin); } @@ -48,10 +55,37 @@ namespace NzbDrone.Integration.Test [Test] public void should_have_allow_methods_in_response() { - var request = BuildRequest(); - var response = RestClient.Get(request); + var request = BuildOptions(); + var response = RestClient.Execute(request); response.Headers.Should().Contain(h => h.Name == AccessControlHeaders.AllowMethods); } + + [Test] + public void should_not_have_allow_methods_in_non_options_request() + { + var request = BuildGet(); + var response = RestClient.Execute(request); + + response.Headers.Should().NotContain(h => h.Name == AccessControlHeaders.AllowMethods); + } + + [Test] + public void should_have_allow_origin_in_non_options_request() + { + var request = BuildGet(); + var response = RestClient.Execute(request); + + response.Headers.Should().Contain(h => h.Name == AccessControlHeaders.AllowOrigin); + } + + [Test] + public void should_not_have_allow_origin_in_non_api_request() + { + var request = BuildGet("../abc"); + var response = RestClient.Execute(request); + + response.Headers.Should().NotContain(h => h.Name == AccessControlHeaders.AllowOrigin); + } } } 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..39e5a981a 100644 --- a/src/NzbDrone.Integration.Test/NzbDrone.Integration.Test.csproj +++ b/src/NzbDrone.Integration.Test/NzbDrone.Integration.Test.csproj @@ -62,6 +62,10 @@ <SpecificVersion>False</SpecificVersion> <HintPath>..\packages\Microsoft.Owin.Hosting.2.1.0\lib\net40\Microsoft.Owin.Hosting.dll</HintPath> </Reference> + <Reference Include="Moq, Version=4.0.10827.0, Culture=neutral, PublicKeyToken=69f491c39445e920, processorArchitecture=MSIL"> + <HintPath>..\packages\Moq.4.0.10827\lib\NET40\Moq.dll</HintPath> + <Private>True</Private> + </Reference> <Reference Include="Nancy, Version=1.4.2.0, Culture=neutral, processorArchitecture=MSIL"> <HintPath>..\packages\Nancy.1.4.3\lib\net40\Nancy.dll</HintPath> <Private>True</Private> @@ -75,7 +79,7 @@ <Private>True</Private> </Reference> <Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL"> - <HintPath>..\packages\NLog.4.4.3\lib\net40\NLog.dll</HintPath> + <HintPath>..\packages\NLog.4.5.3\lib\net40-client\NLog.dll</HintPath> </Reference> <Reference Include="nunit.framework, Version=3.6.0.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> <HintPath>..\packages\NUnit.3.6.0\lib\net40\nunit.framework.dll</HintPath> @@ -85,15 +89,16 @@ <Private>True</Private> </Reference> <Reference Include="System" /> + <Reference Include="System.Configuration" /> <Reference Include="System.Core" /> <Reference Include="System.Data" /> <Reference Include="System.Data.DataSetExtensions" /> + <Reference Include="System.Runtime.Serialization" /> + <Reference Include="System.ServiceModel" /> + <Reference Include="System.Transactions" /> <Reference Include="System.Xml" /> <Reference Include="System.Xml.Linq" /> <Reference Include="Microsoft.CSharp" /> - <Reference Include="Moq"> - <HintPath>..\packages\Moq.4.0.10827\lib\NET40\Moq.dll</HintPath> - </Reference> <Reference Include="Owin"> <HintPath>..\packages\Owin.1.0\lib\net40\Owin.dll</HintPath> </Reference> @@ -105,14 +110,17 @@ <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="ApiTests\WantedTests\CutoffUnmetFixture.cs" /> + <Compile Include="ApiTests\WantedTests\MissingFixture.cs" /> <Compile Include="Client\ClientBase.cs" /> <Compile Include="Client\EpisodeClient.cs" /> <Compile Include="Client\IndexerClient.cs" /> <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.Integration.Test/packages.config b/src/NzbDrone.Integration.Test/packages.config index e82b24d7f..444135304 100644 --- a/src/NzbDrone.Integration.Test/packages.config +++ b/src/NzbDrone.Integration.Test/packages.config @@ -10,7 +10,7 @@ <package id="Nancy" version="1.4.3" targetFramework="net40" /> <package id="Nancy.Owin" version="1.4.1" targetFramework="net40" /> <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net40" /> - <package id="NLog" version="4.4.3" targetFramework="net40" /> + <package id="NLog" version="4.5.3" targetFramework="net40" /> <package id="NUnit" version="3.6.0" targetFramework="net40" /> <package id="Owin" version="1.0" targetFramework="net40" /> <package id="RestSharp" version="105.2.3" targetFramework="net40" /> diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs index 47cb7fccc..88785c650 100644 --- a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs +++ b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs @@ -1,4 +1,6 @@ using System; +using System.IO; +using FluentAssertions; using Mono.Unix; using NUnit.Framework; using NzbDrone.Common.Test.DiskTests; @@ -35,5 +37,55 @@ namespace NzbDrone.Mono.Test.DiskProviderTests entry.FileAccessPermissions &= ~(FileAccessPermissions.UserWrite | FileAccessPermissions.GroupWrite | FileAccessPermissions.OtherWrite); } } + + [Test] + public void should_move_symlink() + { + var tempFolder = GetTempFilePath(); + Directory.CreateDirectory(tempFolder); + + var file = Path.Combine(tempFolder, "target.txt"); + var source = Path.Combine(tempFolder, "symlink_source.txt"); + var destination = Path.Combine(tempFolder, "symlink_destination.txt"); + + File.WriteAllText(file, "Some content"); + + new UnixSymbolicLinkInfo(source).CreateSymbolicLinkTo(file); + + Subject.MoveFile(source, destination); + + File.Exists(file).Should().BeTrue(); + File.Exists(source).Should().BeFalse(); + File.Exists(destination).Should().BeTrue(); + UnixFileSystemInfo.GetFileSystemEntry(destination).IsSymbolicLink.Should().BeTrue(); + + File.ReadAllText(destination).Should().Be("Some content"); + } + + [Test] + public void should_copy_symlink() + { + var tempFolder = GetTempFilePath(); + Directory.CreateDirectory(tempFolder); + + var file = Path.Combine(tempFolder, "target.txt"); + var source = Path.Combine(tempFolder, "symlink_source.txt"); + var destination = Path.Combine(tempFolder, "symlink_destination.txt"); + + File.WriteAllText(file, "Some content"); + + new UnixSymbolicLinkInfo(source).CreateSymbolicLinkTo(file); + + Subject.CopyFile(source, destination); + + File.Exists(file).Should().BeTrue(); + File.Exists(source).Should().BeTrue(); + File.Exists(destination).Should().BeTrue(); + UnixFileSystemInfo.GetFileSystemEntry(source).IsSymbolicLink.Should().BeTrue(); + UnixFileSystemInfo.GetFileSystemEntry(destination).IsSymbolicLink.Should().BeTrue(); + + File.ReadAllText(source).Should().Be("Some content"); + File.ReadAllText(destination).Should().Be("Some content"); + } } } diff --git a/src/NzbDrone.Mono.Test/EnvironmentInfo/MonoPlatformInfoFixture.cs b/src/NzbDrone.Mono.Test/EnvironmentInfo/MonoPlatformInfoFixture.cs index 3e0c17794..431dab769 100644 --- a/src/NzbDrone.Mono.Test/EnvironmentInfo/MonoPlatformInfoFixture.cs +++ b/src/NzbDrone.Mono.Test/EnvironmentInfo/MonoPlatformInfoFixture.cs @@ -13,8 +13,11 @@ namespace NzbDrone.Mono.Test.EnvironmentInfo [Test] public void should_get_framework_version() { - Subject.Version.Major.Should().Be(4); - Subject.Version.Minor.Should().BeOneOf(0, 5, 6); + Subject.Version.Major.Should().BeOneOf(4, 5); + if (Subject.Version.Major == 4) + { + Subject.Version.Minor.Should().BeOneOf(0, 5, 6); + } } } } diff --git a/src/NzbDrone.Mono/Disk/DiskProvider.cs b/src/NzbDrone.Mono/Disk/DiskProvider.cs index d9f3cba29..19649f2b4 100644 --- a/src/NzbDrone.Mono/Disk/DiskProvider.cs +++ b/src/NzbDrone.Mono/Disk/DiskProvider.cs @@ -40,24 +40,15 @@ namespace NzbDrone.Mono.Disk { Ensure.That(path, () => path).IsValidPath(); - try - { - var mount = GetMount(path); + var mount = GetMount(path); - if (mount == null) - { - Logger.Debug("Unable to get free space for '{0}', unable to find suitable drive", path); - return null; - } - - return mount.AvailableFreeSpace; - } - catch (InvalidOperationException ex) + if (mount == null) { - Logger.Error(ex, "Couldn't get free space for {0}", path); + Logger.Debug("Unable to get free space for '{0}', unable to find suitable drive", path); + return null; } - return null; + return mount.AvailableFreeSpace; } public override void InheritFolderPermissions(string filename) @@ -86,38 +77,109 @@ 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) { Ensure.That(path, () => path).IsValidPath(); - try + var mount = GetMount(path); + + return mount?.TotalSize; + } + + protected override void CopyFileInternal(string source, string destination, bool overwrite) + { + var sourceInfo = UnixFileSystemInfo.GetFileSystemEntry(source); + + if (sourceInfo.IsSymbolicLink) { - var mount = GetMount(path); + var isSameDir = UnixPath.GetDirectoryName(source) == UnixPath.GetDirectoryName(destination); + var symlinkInfo = (UnixSymbolicLinkInfo)sourceInfo; + var symlinkPath = symlinkInfo.ContentsPath; - if (mount == null) return null; + var newFile = new UnixSymbolicLinkInfo(destination); - return mount.TotalSize; + if (FileExists(destination) && overwrite) + { + DeleteFile(destination); + } + + if (isSameDir) + { + // We're in the same dir, so we can preserve relative symlinks. + newFile.CreateSymbolicLinkTo(symlinkInfo.ContentsPath); + } + else + { + var fullPath = UnixPath.Combine(UnixPath.GetDirectoryName(source), symlinkPath); + newFile.CreateSymbolicLinkTo(fullPath); + } } - catch (InvalidOperationException e) + else { - Logger.Error(e, "Couldn't get total space for {0}", path); + base.CopyFileInternal(source, destination, overwrite); } + } - return null; + protected override void MoveFileInternal(string source, string destination) + { + var sourceInfo = UnixFileSystemInfo.GetFileSystemEntry(source); + + if (sourceInfo.IsSymbolicLink) + { + var isSameDir = UnixPath.GetDirectoryName(source) == UnixPath.GetDirectoryName(destination); + var symlinkInfo = (UnixSymbolicLinkInfo)sourceInfo; + var symlinkPath = symlinkInfo.ContentsPath; + + var newFile = new UnixSymbolicLinkInfo(destination); + + if (isSameDir) + { + // We're in the same dir, so we can preserve relative symlinks. + newFile.CreateSymbolicLinkTo(symlinkInfo.ContentsPath); + } + else + { + var fullPath = UnixPath.Combine(UnixPath.GetDirectoryName(source), symlinkPath); + newFile.CreateSymbolicLinkTo(fullPath); + } + + try + { + // Finally remove the original symlink. + symlinkInfo.Delete(); + } + catch + { + // Removing symlink failed, so rollback the new link and throw. + newFile.Delete(); + throw; + } + } + else + { + base.MoveFileInternal(source, destination); + } } public override bool TryCreateHardLink(string source, string destination) { try { - UnixFileSystemInfo.GetFileSystemEntry(source).CreateLink(destination); + var fileInfo = UnixFileSystemInfo.GetFileSystemEntry(source); + + if (fileInfo.IsSymbolicLink) return false; + + fileInfo.CreateLink(destination); return true; } catch (Exception ex) diff --git a/src/NzbDrone.Mono/Disk/FindDriveType.cs b/src/NzbDrone.Mono/Disk/FindDriveType.cs index 80f4ab252..4a14feaf0 100644 --- a/src/NzbDrone.Mono/Disk/FindDriveType.cs +++ b/src/NzbDrone.Mono/Disk/FindDriveType.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using NzbDrone.Common.Extensions; @@ -9,6 +9,7 @@ namespace NzbDrone.Mono.Disk private static readonly Dictionary<string, DriveType> DriveTypeMap = new Dictionary<string, DriveType> { { "afpfs", DriveType.Network }, + { "apfs", DriveType.Fixed }, { "zfs", DriveType.Fixed } }; diff --git a/src/NzbDrone.Mono/Disk/ProcMount.cs b/src/NzbDrone.Mono/Disk/ProcMount.cs index 87e428112..154af2971 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=") || Name == VolumeLabel) { 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.Mono/EnvironmentInfo/VersionAdapters/SynologyVersionAdapter.cs b/src/NzbDrone.Mono/EnvironmentInfo/VersionAdapters/SynologyVersionAdapter.cs index 62150f829..02bba34b1 100644 --- a/src/NzbDrone.Mono/EnvironmentInfo/VersionAdapters/SynologyVersionAdapter.cs +++ b/src/NzbDrone.Mono/EnvironmentInfo/VersionAdapters/SynologyVersionAdapter.cs @@ -45,7 +45,7 @@ namespace NzbDrone.Mono.EnvironmentInfo.VersionAdapters if (parts.Length >= 2) { var key = parts[0]; - var value = parts[1]; + var value = parts[1].Trim('"'); if (!string.IsNullOrWhiteSpace(value)) { @@ -75,4 +75,4 @@ namespace NzbDrone.Mono.EnvironmentInfo.VersionAdapters public bool Enabled => OsInfo.IsLinux; } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Mono/NzbDrone.Mono.csproj b/src/NzbDrone.Mono/NzbDrone.Mono.csproj index c8532b6f0..50ef3a188 100644 --- a/src/NzbDrone.Mono/NzbDrone.Mono.csproj +++ b/src/NzbDrone.Mono/NzbDrone.Mono.csproj @@ -52,12 +52,16 @@ </PropertyGroup> <ItemGroup> <Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL"> - <HintPath>..\packages\NLog.4.4.3\lib\net40\NLog.dll</HintPath> + <HintPath>..\packages\NLog.4.5.3\lib\net40-client\NLog.dll</HintPath> </Reference> <Reference Include="System" /> + <Reference Include="System.Configuration" /> <Reference Include="System.Core" /> <Reference Include="System.Data" /> <Reference Include="System.Data.DataSetExtensions" /> + <Reference Include="System.Runtime.Serialization" /> + <Reference Include="System.ServiceModel" /> + <Reference Include="System.Transactions" /> <Reference Include="System.Xml" /> <Reference Include="System.Xml.Linq" /> <Reference Include="Microsoft.CSharp" /> diff --git a/src/NzbDrone.Mono/packages.config b/src/NzbDrone.Mono/packages.config index a14101dce..6f6ef792a 100644 --- a/src/NzbDrone.Mono/packages.config +++ b/src/NzbDrone.Mono/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="NLog" version="4.4.3" targetFramework="net40" /> + <package id="NLog" version="4.5.3" targetFramework="net40" /> </packages> \ No newline at end of file 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/LoggingTest.cs b/src/NzbDrone.Test.Common/LoggingTest.cs index b8aba6dcd..7765aff1b 100644 --- a/src/NzbDrone.Test.Common/LoggingTest.cs +++ b/src/NzbDrone.Test.Common/LoggingTest.cs @@ -1,3 +1,5 @@ +using System; +using System.IO; using NLog; using NLog.Config; using NLog.Targets; @@ -20,9 +22,19 @@ namespace NzbDrone.Test.Common if (LogManager.Configuration == null || LogManager.Configuration.AllTargets.None(c => c is ExceptionVerification)) { LogManager.Configuration = new LoggingConfiguration(); - var consoleTarget = new ConsoleTarget { Layout = "${level}: ${message} ${exception}" }; - LogManager.Configuration.AddTarget(consoleTarget.GetType().Name, consoleTarget); - LogManager.Configuration.LoggingRules.Add(new LoggingRule("*", LogLevel.Trace, consoleTarget)); + + var logOutput = TestLogOutput.Console; + Enum.TryParse<TestLogOutput>(Environment.GetEnvironmentVariable("SONARR_TESTS_LOG_OUTPUT"), out logOutput); + + switch (logOutput) + { + case TestLogOutput.Console: + RegisterConsoleLogger(); + break; + case TestLogOutput.File: + RegisterFileLogger(); + break; + } RegisterExceptionVerification(); @@ -30,6 +42,32 @@ namespace NzbDrone.Test.Common } } + private static void RegisterConsoleLogger() + { + var consoleTarget = new ConsoleTarget { Layout = "${level}: ${message} ${exception}" }; + LogManager.Configuration.AddTarget(consoleTarget.GetType().Name, consoleTarget); + LogManager.Configuration.LoggingRules.Add(new LoggingRule("*", LogLevel.Trace, consoleTarget)); + } + + private static void RegisterFileLogger() + { + const string layout = @"${level}|${message}${onexception:inner=${newline}${newline}${exception:format=ToString}${newline}}"; + + var fileTarget = new FileTarget(); + + fileTarget.Name = "Test File Logger"; + fileTarget.FileName = Path.Combine(TestContext.CurrentContext.WorkDirectory, "TestLog.txt"); + fileTarget.AutoFlush = false; + fileTarget.KeepFileOpen = true; + fileTarget.ConcurrentWrites = true; + fileTarget.ConcurrentWriteAttemptDelay = 50; + fileTarget.ConcurrentWriteAttempts = 10; + fileTarget.Layout = layout; + + LogManager.Configuration.AddTarget(fileTarget.GetType().Name, fileTarget); + LogManager.Configuration.LoggingRules.Add(new LoggingRule("*", LogLevel.Trace, fileTarget)); + } + private static void RegisterExceptionVerification() { var exceptionVerification = new ExceptionVerification(); @@ -42,6 +80,7 @@ namespace NzbDrone.Test.Common { InitLogging(); ExceptionVerification.Reset(); + TestLogger.Info("--- Start: {0} ---", TestContext.CurrentContext.Test.FullName); } [TearDown] @@ -53,6 +92,8 @@ namespace NzbDrone.Test.Common { ExceptionVerification.AssertNoUnexpectedLogs(); } + + TestLogger.Info("--- End: {0} ---", TestContext.CurrentContext.Test.FullName); } } } diff --git a/src/NzbDrone.Test.Common/NzbDrone.Test.Common.csproj b/src/NzbDrone.Test.Common/NzbDrone.Test.Common.csproj index 08bf03d3f..989ba8739 100644 --- a/src/NzbDrone.Test.Common/NzbDrone.Test.Common.csproj +++ b/src/NzbDrone.Test.Common/NzbDrone.Test.Common.csproj @@ -43,12 +43,19 @@ <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="Moq, Version=4.0.10827.0, Culture=neutral, PublicKeyToken=69f491c39445e920, processorArchitecture=MSIL"> + <HintPath>..\packages\Moq.4.0.10827\lib\NET40\Moq.dll</HintPath> + <Private>True</Private> + </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> </Reference> <Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL"> - <HintPath>..\packages\NLog.4.4.3\lib\net40\NLog.dll</HintPath> + <HintPath>..\packages\NLog.4.5.3\lib\net40-client\NLog.dll</HintPath> </Reference> <Reference Include="nunit.framework, Version=3.6.0.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> <HintPath>..\packages\NUnit.3.6.0\lib\net40\nunit.framework.dll</HintPath> @@ -58,9 +65,13 @@ <Private>True</Private> </Reference> <Reference Include="System" /> + <Reference Include="System.Configuration" /> <Reference Include="System.Core" /> <Reference Include="System.Data" /> <Reference Include="System.Data.DataSetExtensions" /> + <Reference Include="System.Runtime.Serialization" /> + <Reference Include="System.ServiceModel" /> + <Reference Include="System.Transactions" /> <Reference Include="System.Xml" /> <Reference Include="System.Xml.Linq" /> <Reference Include="Microsoft.CSharp" /> @@ -73,9 +84,6 @@ <Reference Include="Microsoft.Practices.Unity.Configuration"> <HintPath>..\packages\Unity.2.1.505.2\lib\NET35\Microsoft.Practices.Unity.Configuration.dll</HintPath> </Reference> - <Reference Include="Moq"> - <HintPath>..\packages\Moq.4.0.10827\lib\NET40\Moq.dll</HintPath> - </Reference> </ItemGroup> <ItemGroup> <Compile Include="AutoMoq\AutoMoqer.cs" /> @@ -94,6 +102,8 @@ <Compile Include="StringExtensions.cs" /> <Compile Include="TestBase.cs" /> <Compile Include="TestException.cs" /> + <Compile Include="TestLogOutput.cs" /> + <Compile Include="TestValidator.cs" /> </ItemGroup> <ItemGroup> <Content Include="AutoMoq\License.txt" /> diff --git a/src/NzbDrone.Test.Common/TestLogOutput.cs b/src/NzbDrone.Test.Common/TestLogOutput.cs new file mode 100644 index 000000000..91cf2d52d --- /dev/null +++ b/src/NzbDrone.Test.Common/TestLogOutput.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Test.Common +{ + public enum TestLogOutput + { + Console = 0, + File = 1, + None = 2 + } +} 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..043baa4cd 100644 --- a/src/NzbDrone.Test.Common/packages.config +++ b/src/NzbDrone.Test.Common/packages.config @@ -2,9 +2,10 @@ <packages> <package id="CommonServiceLocator" version="1.3" targetFramework="net40" /> <package id="FluentAssertions" version="4.19.0" targetFramework="net40" /> - <package id="Moq" version="4.0.10827" /> + <package id="FluentValidation" version="6.2.1.0" targetFramework="net40" /> + <package id="Moq" version="4.0.10827" targetFramework="net40" /> <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net40" /> - <package id="NLog" version="4.4.3" targetFramework="net40" /> + <package id="NLog" version="4.5.3" targetFramework="net40" /> <package id="NUnit" version="3.6.0" targetFramework="net40" /> <package id="RestSharp" version="105.2.3" targetFramework="net40" /> <package id="Unity" version="2.1.505.2" targetFramework="net40" /> diff --git a/src/NzbDrone.Update.Test/NzbDrone.Update.Test.csproj b/src/NzbDrone.Update.Test/NzbDrone.Update.Test.csproj index e3c6dc4de..fc059ebb3 100644 --- a/src/NzbDrone.Update.Test/NzbDrone.Update.Test.csproj +++ b/src/NzbDrone.Update.Test/NzbDrone.Update.Test.csproj @@ -47,22 +47,27 @@ <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="Moq, Version=4.0.10827.0, Culture=neutral, PublicKeyToken=69f491c39445e920, processorArchitecture=MSIL"> + <HintPath>..\packages\Moq.4.0.10827\lib\NET40\Moq.dll</HintPath> + <Private>True</Private> + </Reference> <Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL"> - <HintPath>..\packages\NLog.4.4.3\lib\net40\NLog.dll</HintPath> + <HintPath>..\packages\NLog.4.5.3\lib\net40-client\NLog.dll</HintPath> </Reference> <Reference Include="nunit.framework, Version=3.6.0.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> <HintPath>..\packages\NUnit.3.6.0\lib\net40\nunit.framework.dll</HintPath> </Reference> <Reference Include="System" /> + <Reference Include="System.Configuration" /> <Reference Include="System.Core" /> <Reference Include="System.Data" /> <Reference Include="System.Data.DataSetExtensions" /> + <Reference Include="System.Runtime.Serialization" /> + <Reference Include="System.ServiceModel" /> + <Reference Include="System.Transactions" /> <Reference Include="System.Xml" /> <Reference Include="System.Xml.Linq" /> <Reference Include="Microsoft.CSharp" /> - <Reference Include="Moq"> - <HintPath>..\packages\Moq.4.0.10827\lib\NET40\Moq.dll</HintPath> - </Reference> </ItemGroup> <ItemGroup> <Compile Include="InstallUpdateServiceFixture.cs" /> diff --git a/src/NzbDrone.Update.Test/packages.config b/src/NzbDrone.Update.Test/packages.config index dc7aef2ad..81a4765e8 100644 --- a/src/NzbDrone.Update.Test/packages.config +++ b/src/NzbDrone.Update.Test/packages.config @@ -1,8 +1,8 @@ <?xml version="1.0" encoding="utf-8"?> <packages> <package id="FluentAssertions" version="4.19.0" targetFramework="net40" /> - <package id="Moq" version="4.0.10827" /> + <package id="Moq" version="4.0.10827" targetFramework="net40" /> <package id="NBuilder" version="4.0.0" targetFramework="net40" /> - <package id="NLog" version="4.4.3" targetFramework="net40" /> + <package id="NLog" version="4.5.3" targetFramework="net40" /> <package id="NUnit" version="3.6.0" targetFramework="net40" /> </packages> \ No newline at end of file diff --git a/src/NzbDrone.Update/NzbDrone.Update.csproj b/src/NzbDrone.Update/NzbDrone.Update.csproj index d4e0a5e41..2e82da4c5 100644 --- a/src/NzbDrone.Update/NzbDrone.Update.csproj +++ b/src/NzbDrone.Update/NzbDrone.Update.csproj @@ -40,15 +40,22 @@ <ApplicationManifest>app.manifest</ApplicationManifest> </PropertyGroup> <ItemGroup> + <Reference Include="Microsoft.CSharp" /> <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> </Reference> <Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL"> - <HintPath>..\packages\NLog.4.4.3\lib\net40\NLog.dll</HintPath> + <HintPath>..\packages\NLog.4.5.3\lib\net40-client\NLog.dll</HintPath> </Reference> <Reference Include="System" /> + <Reference Include="System.Configuration" /> <Reference Include="System.Core" /> + <Reference Include="System.Data" /> + <Reference Include="System.Runtime.Serialization" /> + <Reference Include="System.ServiceModel" /> + <Reference Include="System.Transactions" /> + <Reference Include="System.Xml" /> </ItemGroup> <ItemGroup> <Compile Include="..\NzbDrone.Common\Properties\SharedAssemblyInfo.cs"> 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/NzbDrone.Update/packages.config b/src/NzbDrone.Update/packages.config index 96f5ba1e0..bd5160055 100644 --- a/src/NzbDrone.Update/packages.config +++ b/src/NzbDrone.Update/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net40" /> - <package id="NLog" version="4.4.3" targetFramework="net40" /> + <package id="NLog" version="4.5.3" targetFramework="net40" /> </packages> \ No newline at end of file diff --git a/src/NzbDrone.Windows/NzbDrone.Windows.csproj b/src/NzbDrone.Windows/NzbDrone.Windows.csproj index 6e7f84bbf..9011e93a4 100644 --- a/src/NzbDrone.Windows/NzbDrone.Windows.csproj +++ b/src/NzbDrone.Windows/NzbDrone.Windows.csproj @@ -51,12 +51,16 @@ </PropertyGroup> <ItemGroup> <Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL"> - <HintPath>..\packages\NLog.4.4.3\lib\net40\NLog.dll</HintPath> + <HintPath>..\packages\NLog.4.5.3\lib\net40-client\NLog.dll</HintPath> </Reference> <Reference Include="System" /> + <Reference Include="System.Configuration" /> <Reference Include="System.Core" /> <Reference Include="System.Data" /> <Reference Include="System.Data.DataSetExtensions" /> + <Reference Include="System.Runtime.Serialization" /> + <Reference Include="System.ServiceModel" /> + <Reference Include="System.Transactions" /> <Reference Include="System.Xml" /> <Reference Include="System.Xml.Linq" /> <Reference Include="Microsoft.CSharp" /> diff --git a/src/NzbDrone.Windows/packages.config b/src/NzbDrone.Windows/packages.config index a14101dce..6f6ef792a 100644 --- a/src/NzbDrone.Windows/packages.config +++ b/src/NzbDrone.Windows/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="NLog" version="4.4.3" targetFramework="net40" /> + <package id="NLog" version="4.5.3" targetFramework="net40" /> </packages> \ No newline at end of file diff --git a/src/NzbDrone/NzbDrone.csproj b/src/NzbDrone/NzbDrone.csproj index 70807d996..0e2e040a8 100644 --- a/src/NzbDrone/NzbDrone.csproj +++ b/src/NzbDrone/NzbDrone.csproj @@ -66,6 +66,7 @@ <ApplicationManifest>app.manifest</ApplicationManifest> </PropertyGroup> <ItemGroup> + <Reference Include="Microsoft.CSharp" /> <Reference Include="Microsoft.Owin, Version=2.1.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL"> <SpecificVersion>False</SpecificVersion> <HintPath>..\packages\Microsoft.Owin.2.1.0\lib\net40\Microsoft.Owin.dll</HintPath> @@ -79,15 +80,21 @@ <Private>True</Private> </Reference> <Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL"> - <HintPath>..\packages\NLog.4.4.3\lib\net40\NLog.dll</HintPath> + <HintPath>..\packages\NLog.4.5.3\lib\net40-client\NLog.dll</HintPath> </Reference> <Reference Include="System" /> + <Reference Include="System.Configuration" /> <Reference Include="System.Core" /> + <Reference Include="System.Data" /> <Reference Include="System.Drawing" /> + <Reference Include="System.Runtime.Serialization" /> + <Reference Include="System.ServiceModel" /> + <Reference Include="System.Transactions" /> <Reference Include="System.Windows.Forms" /> <Reference Include="Owin"> <HintPath>..\packages\Owin.1.0\lib\net40\Owin.dll</HintPath> </Reference> + <Reference Include="System.Xml" /> </ItemGroup> <ItemGroup> <Compile Include="..\NzbDrone.Common\Properties\SharedAssemblyInfo.cs"> diff --git a/src/NzbDrone/packages.config b/src/NzbDrone/packages.config index 11b77285e..f3188f1a4 100644 --- a/src/NzbDrone/packages.config +++ b/src/NzbDrone/packages.config @@ -3,6 +3,6 @@ <package id="Microsoft.Owin" version="2.1.0" targetFramework="net40" /> <package id="Microsoft.Owin.Hosting" version="2.1.0" targetFramework="net40" /> <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net40" /> - <package id="NLog" version="4.4.3" targetFramework="net40" /> + <package id="NLog" version="4.5.3" targetFramework="net40" /> <package id="Owin" version="1.0" targetFramework="net40" /> </packages> \ No newline at end of file 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/AddSeries/AddSeriesView.js b/src/UI/AddSeries/AddSeriesView.js index 3cda1db63..9f575ee69 100644 --- a/src/UI/AddSeries/AddSeriesView.js +++ b/src/UI/AddSeries/AddSeriesView.js @@ -55,7 +55,7 @@ module.exports = Marionette.Layout.extend({ this.$el.addClass(this.className); - this.ui.seriesSearch.keyup(function(e) { + this.ui.seriesSearch.on('input', function(e) { if (_.contains([ 9, @@ -179,4 +179,4 @@ module.exports = Marionette.Layout.extend({ this.collection.term = ''; } } -}); \ No newline at end of file +}); diff --git a/src/UI/Calendar/CalendarView.js b/src/UI/Calendar/CalendarView.js index 871db9343..606f11fc8 100644 --- a/src/UI/Calendar/CalendarView.js +++ b/src/UI/Calendar/CalendarView.js @@ -151,7 +151,7 @@ module.exports = Marionette.ItemView.extend({ var seriesTitle = model.get('series').title; var start = model.get('airDateUtc'); var runtime = model.get('series').runtime; - var end = moment(start).add('minutes', runtime).toISOString(); + var end = moment(start).add(runtime, 'minutes').toISOString(); var event = { title : seriesTitle, @@ -281,4 +281,4 @@ module.exports = Marionette.ItemView.extend({ this.$('.fc-day-grid-container').css('height', ''); this.$('.fc-row.fc-widget-header').attr('style', ''); } -}); \ No newline at end of file +}); diff --git a/src/UI/Calendar/UpcomingItemView.js b/src/UI/Calendar/UpcomingItemView.js index f0b8eb18c..8c96788ed 100644 --- a/src/UI/Calendar/UpcomingItemView.js +++ b/src/UI/Calendar/UpcomingItemView.js @@ -13,7 +13,7 @@ module.exports = Marionette.ItemView.extend({ initialize : function() { var start = this.model.get('airDateUtc'); var runtime = this.model.get('series').runtime; - var end = moment(start).add('minutes', runtime); + var end = moment(start).add(runtime, 'minutes'); this.model.set({ end : end.toISOString() @@ -25,4 +25,4 @@ module.exports = Marionette.ItemView.extend({ _showEpisodeDetails : function() { vent.trigger(vent.Commands.ShowEpisodeDetails, { episode : this.model }); } -}); \ 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/Cells/RelativeDateCell.js b/src/UI/Cells/RelativeDateCell.js index eb69fc855..df147b6a2 100644 --- a/src/UI/Cells/RelativeDateCell.js +++ b/src/UI/Cells/RelativeDateCell.js @@ -12,7 +12,7 @@ module.exports = NzbDroneCell.extend({ if (dateStr) { var date = moment(dateStr); - var diff = date.diff(moment().zone(date.zone()).startOf('day'), 'days', true); + var diff = date.diff(moment().utcOffset(date.utcOffset()).startOf('day'), 'days', true); var result = '<span title="{0}">{1}</span>'; var tooltip = date.format(UiSettings.longDateTime()); var text; @@ -31,4 +31,4 @@ module.exports = NzbDroneCell.extend({ } return this; } -}); \ No newline at end of file +}); diff --git a/src/UI/Commands/CommandController.js b/src/UI/Commands/CommandController.js index 2232d45ae..2007879fd 100644 --- a/src/UI/Commands/CommandController.js +++ b/src/UI/Commands/CommandController.js @@ -20,7 +20,7 @@ var singleton = function() { var attr = _.extend({ name : name.toLocaleLowerCase() }, properties); var commandModel = new CommandModel(attr); - if (this._lastCommand.command && this._lastCommand.command.isSameCommand(attr) && moment().add('seconds', -5).isBefore(this._lastCommand.time)) { + if (this._lastCommand.command && this._lastCommand.command.isSameCommand(attr) && moment().add(-5, 'seconds').isBefore(this._lastCommand.time)) { Messenger.show({ message : 'Please wait at least 5 seconds before running this command again', diff --git a/src/UI/Content/form.less b/src/UI/Content/form.less index 28474c962..0f8c687a5 100644 --- a/src/UI/Content/form.less +++ b/src/UI/Content/form.less @@ -48,6 +48,18 @@ margin-left: 0px; } } + + .unit-inline { + width: 75px; + margin-left: -120px; + margin-top: 7px; + color: lightgray; + float: left; + position: relative; + text-align: right; + user-select: none; + pointer-events: none; + } } .text-area-help { 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/Form/TextboxTemplate.hbs b/src/UI/Form/TextboxTemplate.hbs index e7054cfac..48d396ef8 100644 --- a/src/UI/Form/TextboxTemplate.hbs +++ b/src/UI/Form/TextboxTemplate.hbs @@ -4,5 +4,6 @@ <div class="col-sm-5"> <input type="text" name="fields.{{order}}.value" validation-name="{{name}}" spellcheck="false" class="form-control"/> </div> + {{#if unit}}<span class="unit-inline">{{unit}}</span>{{/if}} {{> FormHelpPartial}} </div> 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/ManualImport/ErrorView.js b/src/UI/ManualImport/ErrorView.js new file mode 100644 index 000000000..8d2a47b80 --- /dev/null +++ b/src/UI/ManualImport/ErrorView.js @@ -0,0 +1,5 @@ +var Marionette = require('marionette'); + +module.exports = Marionette.CompositeView.extend({ + template : 'ManualImport/ErrorViewTemplate' +}); diff --git a/src/UI/ManualImport/ErrorViewTemplate.hbs b/src/UI/ManualImport/ErrorViewTemplate.hbs new file mode 100644 index 000000000..e547d4f88 --- /dev/null +++ b/src/UI/ManualImport/ErrorViewTemplate.hbs @@ -0,0 +1 @@ +Unable to load files for manual import. Check logs for more details. diff --git a/src/UI/ManualImport/ManualImportCollection.js b/src/UI/ManualImport/ManualImportCollection.js index c7cff70f7..b4df7a842 100644 --- a/src/UI/ManualImport/ManualImportCollection.js +++ b/src/UI/ManualImport/ManualImportCollection.js @@ -36,6 +36,12 @@ var Collection = PageableCollection.extend({ }, sortMappings : { + relativePath : { + sortValue : function(model, attr, order) { + return model.get(attr).toLowerCase(); + } + }, + series : { sortValue : function(model, attr, order) { var series = model.get(attr); @@ -71,4 +77,4 @@ var Collection = PageableCollection.extend({ Collection = AsSortedCollection.call(Collection); -module.exports = Collection; \ No newline at end of file +module.exports = Collection; diff --git a/src/UI/ManualImport/ManualImportLayout.js b/src/UI/ManualImport/ManualImportLayout.js index ba5a139fc..54664209f 100644 --- a/src/UI/ManualImport/ManualImportLayout.js +++ b/src/UI/ManualImport/ManualImportLayout.js @@ -4,6 +4,7 @@ var Marionette = require('marionette'); var Backgrid = require('backgrid'); var CommandController = require('../Commands/CommandController'); var EmptyView = require('./EmptyView'); +var ErrorView = require('./ErrorView'); var SelectFolderView = require('./Folder/SelectFolderView'); var LoadingView = require('../Shared/LoadingView'); var ManualImportRow = require('./ManualImportRow'); @@ -122,8 +123,11 @@ module.exports = Marionette.Layout.extend({ }, _loadCollection : function () { + var self = this; this.manualImportCollection = new ManualImportCollection({ folder: this.folder, downloadId: this.downloadId }); - this.manualImportCollection.fetch(); + this.manualImportCollection.fetch().fail(function () { + self.workspace.show(new ErrorView()); + }); this.listenTo(this.manualImportCollection, 'sync', this._showTable); this.listenTo(this.manualImportCollection, 'backgrid:selected', this._updateButtons); @@ -207,6 +211,7 @@ module.exports = Marionette.Layout.extend({ files : _.map(selected, function (file) { return { path : file.get('path'), + folderName : file.get('folderName'), seriesId : file.get('series').id, episodeIds : _.map(file.get('episodes'), 'id'), quality : file.get('quality'), @@ -256,4 +261,4 @@ module.exports = Marionette.Layout.extend({ hideAfter : 5 }); } -}); \ No newline at end of file +}); 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/Series/Details/SeasonLayout.js b/src/UI/Series/Details/SeasonLayout.js index cf10b6fa8..231810647 100644 --- a/src/UI/Series/Details/SeasonLayout.js +++ b/src/UI/Series/Details/SeasonLayout.js @@ -214,8 +214,8 @@ module.exports = Marionette.Layout.extend({ }, _shouldShowEpisodes : function() { - var startDate = moment().add('month', -1); - var endDate = moment().add('year', 1); + var startDate = moment().add(-1, 'month'); + var endDate = moment().add(1, 'year'); return this.episodeCollection.some(function(episode) { var airDate = episode.get('airDateUtc'); @@ -298,4 +298,4 @@ module.exports = Marionette.Layout.extend({ vent.trigger(vent.Commands.OpenModalCommand, view); } -}); \ No newline at end of file +}); diff --git a/src/UI/Series/SeriesCollection.js b/src/UI/Series/SeriesCollection.js index bef8fe338..42e23fd5e 100644 --- a/src/UI/Series/SeriesCollection.js +++ b/src/UI/Series/SeriesCollection.js @@ -94,8 +94,9 @@ var Collection = PageableCollection.extend({ percentOfEpisodes : { sortValue : function(model, attr) { - var percentOfEpisodes = model.get(attr); var episodeCount = model.get('episodeCount'); + var episodeFileCount = model.get('episodeFileCount'); + var percentOfEpisodes = episodeCount ? episodeFileCount / episodeCount * 100 : 100; return percentOfEpisodes + episodeCount / 1000000; } diff --git a/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryViewTemplate.hbs b/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryViewTemplate.hbs index 9043ad2f5..b141fbb78 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> @@ -25,5 +28,7 @@ <div class="col-sm-2 col-sm-pull-1"> <input type="number" name="downloadedEpisodesScanInterval" class="form-control" /> </div> + + <span class="col-sm-pull-1 unit-inline">minutes</span> </div> -</fieldset> \ No newline at end of file +</fieldset> diff --git a/src/UI/Settings/Indexers/Options/IndexerOptionsViewTemplate.hbs b/src/UI/Settings/Indexers/Options/IndexerOptionsViewTemplate.hbs index 056d12648..96da24d20 100644 --- a/src/UI/Settings/Indexers/Options/IndexerOptionsViewTemplate.hbs +++ b/src/UI/Settings/Indexers/Options/IndexerOptionsViewTemplate.hbs @@ -11,6 +11,8 @@ <div class="col-sm-2 col-sm-pull-1"> <input type="number" min="0" name="minimumAge" class="form-control"/> </div> + + <span class="col-sm-pull-1 unit-inline">minutes</span> </div> <div class="form-group"> @@ -23,6 +25,22 @@ <div class="col-sm-2 col-sm-pull-1"> <input type="number" min="0" name="retention" class="form-control"/> </div> + + <span class="col-sm-pull-1 unit-inline">days</span> + </div> + + <div class="form-group"> + <label class="col-sm-3 control-label">Maximum Size</label> + + <div class="col-sm-1 col-sm-push-2 help-inline"> + <i class="icon-sonarr-form-info" title="Maximum size for a release to be grabbed in MB. Set to zero to set to unlimited"/> + </div> + + <div class="col-sm-2 col-sm-pull-1"> + <input type="number" min="0" name="maximumSize" class="form-control"/> + </div> + + <span class="col-sm-pull-1 unit-inline">MB</span> </div> <div class="form-group advanced-setting"> @@ -31,10 +49,13 @@ <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"> <input type="number" name="rssSyncInterval" class="form-control" min="0" max="120"/> </div> + + <span class="col-sm-pull-1 unit-inline">minutes</span> </div> </fieldset> 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/MediaManagement/Sorting/SortingViewTemplate.hbs b/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs index f3612203f..dc62a7e4d 100644 --- a/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs +++ b/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs @@ -23,6 +23,29 @@ </div> </div> </div> + + <div class="form-group"> + <label class="col-sm-3 control-label">Delete empty folders</label> + + <div class="col-sm-9"> + <div class="input-group"> + <label class="checkbox toggle well"> + <input type="checkbox" name="deleteEmptyFolders"/> + + <p> + <span>Yes</span> + <span>No</span> + </p> + + <div class="btn btn-primary slide-button"/> + </label> + + <span class="help-inline-checkbox"> + <i class="icon-sonarr-form-info" title="Delete empty series and season folders during disk scan and when episode files are deleted"/> + </span> + </div> + </div> + </div> </fieldset> <fieldset> 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/Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate.hbs b/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate.hbs index 5ff9c3bea..8f054ad83 100644 --- a/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate.hbs +++ b/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate.hbs @@ -36,6 +36,8 @@ <div class="col-sm-5 col-sm-pull-1"> <input type="number" class="form-control" name="usenetDelay"/> </div> + + <span class="col-sm-pull-1 unit-inline">minutes</span> </div> <div class="form-group x-torrent-delay"> @@ -48,6 +50,8 @@ <div class="col-sm-5 col-sm-pull-1"> <input type="number" class="form-control" name="torrentDelay"/> </div> + + <span class="col-sm-pull-1 unit-inline">minutes</span> </div> {{#if_eq id compare="1"}} diff --git a/src/UI/Settings/Profile/Edit/EditProfileLayout.js b/src/UI/Settings/Profile/Edit/EditProfileLayout.js index 0eb0789d5..7bb9266d2 100644 --- a/src/UI/Settings/Profile/Edit/EditProfileLayout.js +++ b/src/UI/Settings/Profile/Edit/EditProfileLayout.js @@ -103,6 +103,7 @@ var view = Marionette.Layout.extend({ _updateDisableStatus : function() { if (this._isQualityInUse()) { + this.ui.deleteButton.attr('disabled', 'disabled'); this.ui.deleteButton.addClass('disabled'); this.ui.deleteButton.attr('title', 'Can\'t delete a profile that is attached to a series.'); } else { diff --git a/src/UI/Settings/Profile/Edit/EditProfileViewTemplate.hbs b/src/UI/Settings/Profile/Edit/EditProfileViewTemplate.hbs index cae0f2447..aaa4596d4 100644 --- a/src/UI/Settings/Profile/Edit/EditProfileViewTemplate.hbs +++ b/src/UI/Settings/Profile/Edit/EditProfileViewTemplate.hbs @@ -22,7 +22,7 @@ </div> <div class="col-sm-1 help-inline"> - <i class="icon-sonarr-form-info" title="Series assigned this profile will be look for episodes with the selected language"/> + <i class="icon-sonarr-form-info" title="Series assigned this profile will look for episodes with the selected language"/> </div> </div> @@ -40,6 +40,6 @@ </div> <div class="col-sm-1 help-inline"> - <i class="icon-sonarr-form-info" title="Once this quality is reached Sonarr will no longer download episodes"/> + <i class="icon-sonarr-form-info" title="Once this quality is reached, Sonarr will no longer download episodes"/> </div> </div> diff --git a/src/UI/Settings/UI/UiViewTemplate.hbs b/src/UI/Settings/UI/UiViewTemplate.hbs index 5a3d46d27..92923d055 100644 --- a/src/UI/Settings/UI/UiViewTemplate.hbs +++ b/src/UI/Settings/UI/UiViewTemplate.hbs @@ -22,10 +22,10 @@ <div class="col-sm-4 col-sm-pull-1"> <select name="calendarWeekColumnHeader" class="form-control"> - <option value="ddd M/D">Tue 3/5</option> - <option value="ddd MM/DD">Tue 03/05</option> - <option value="ddd D/M">Tue 5/3</option> - <option value="ddd DD/MM">Tue 05/03</option> + <option value="ddd M/D">Tue 3/25</option> + <option value="ddd MM/DD">Tue 03/25</option> + <option value="ddd D/M">Tue 25/3</option> + <option value="ddd DD/MM">Tue 25/03</option> </select> </div> </div> @@ -39,12 +39,12 @@ <div class="col-sm-4"> <select name="shortDateFormat" class="form-control"> - <option value="MMM D YYYY">Mar 5 2014</option> - <option value="DD MMM YYYY">05 Mar 2014</option> - <option value="MM/D/YYYY">03/5/2014</option> - <option value="MM/DD/YYYY">03/05/2014</option> - <option value="DD/MM/YYYY">05/03/2014</option> - <option value="YYYY-MM-DD">2014-03-05</option> + <option value="MMM D YYYY">Mar 25 2014</option> + <option value="DD MMM YYYY">25 Mar 2014</option> + <option value="MM/D/YYYY">03/25/2014</option> + <option value="MM/DD/YYYY">03/25/2014</option> + <option value="DD/MM/YYYY">25/03/2014</option> + <option value="YYYY-MM-DD">2014-03-25</option> </select> </div> </div> @@ -54,8 +54,8 @@ <div class="col-sm-4"> <select name="longDateFormat" class="form-control"> - <option value="dddd, MMMM D YYYY">Tuesday, March 5, 2014</option> - <option value="dddd, D MMMM YYYY">Tuesday, 5 March, 2014</option> + <option value="dddd, MMMM D YYYY">Tuesday, March 25, 2014</option> + <option value="dddd, D MMMM YYYY">Tuesday, 25 March, 2014</option> </select> </div> </div> diff --git a/src/UI/Shared/ErrorModel.js b/src/UI/Shared/ErrorModel.js new file mode 100644 index 000000000..b8ccf4fea --- /dev/null +++ b/src/UI/Shared/ErrorModel.js @@ -0,0 +1,10 @@ +var _ = require('underscore'); +var Backbone = require('backbone'); + +module.exports = Backbone.Model.extend({ + defaults : { + 'type' : 'danger', + 'title' : '', + 'message' : '' + } +}); \ No newline at end of file diff --git a/src/UI/Shared/ErrorView.js b/src/UI/Shared/ErrorView.js new file mode 100644 index 000000000..de52d19d3 --- /dev/null +++ b/src/UI/Shared/ErrorView.js @@ -0,0 +1,10 @@ +var Marionette = require('marionette'); +var ErrorModel = require('./ErrorModel'); + +module.exports = Marionette.ItemView.extend({ + template : 'Shared/ErrorViewTemplate', + + initialize: function(data) { + this.model = new ErrorModel(data); + } +}); \ No newline at end of file diff --git a/src/UI/Shared/ErrorViewTemplate.hbs b/src/UI/Shared/ErrorViewTemplate.hbs new file mode 100644 index 000000000..fb69f67fa --- /dev/null +++ b/src/UI/Shared/ErrorViewTemplate.hbs @@ -0,0 +1,6 @@ +<div class="alert alert-{{type}}" role="alert"> + {{#if title}} + <strong>{{title}}</strong> + {{/if}} + {{message}} +</div> \ No newline at end of file diff --git a/src/UI/Shared/FormatHelpers.js b/src/UI/Shared/FormatHelpers.js index 303f60ff6..3c1703203 100644 --- a/src/UI/Shared/FormatHelpers.js +++ b/src/UI/Shared/FormatHelpers.js @@ -40,7 +40,7 @@ module.exports = { return 'in ' + date.fromNow(true); } - if (date.isBefore(moment().add('years', -1))) { + if (date.isBefore(moment().add(-1, 'years'))) { return date.format(UiSettings.get('shortDateFormat')); } @@ -68,4 +68,4 @@ module.exports = { return unit + 's'; } -}; \ No newline at end of file +}; 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/System/Info/DiskSpace/DiskSpacePathCell.js b/src/UI/System/Info/DiskSpace/DiskSpacePathCell.js index de2ceb9b6..56cbee2c7 100644 --- a/src/UI/System/Info/DiskSpace/DiskSpacePathCell.js +++ b/src/UI/System/Info/DiskSpace/DiskSpacePathCell.js @@ -11,7 +11,7 @@ module.exports = Backgrid.Cell.extend({ var contents = path; - if (label) { + if (label && label !== path && !label.startsWith("UUID=")) { contents += ' ({0})'.format(label); } diff --git a/src/UI/System/Logs/Table/LogTimeCell.js b/src/UI/System/Logs/Table/LogTimeCell.js index 1adbab10e..c82f87c39 100644 --- a/src/UI/System/Logs/Table/LogTimeCell.js +++ b/src/UI/System/Logs/Table/LogTimeCell.js @@ -9,7 +9,7 @@ module.exports = NzbDroneCell.extend({ render : function() { var dateStr = this._getValue(); var date = moment(dateStr); - var diff = date.diff(moment().zone(date.zone()).startOf('day'), 'days', true); + var diff = date.diff(moment().utcOffset(date.utcOffset()).startOf('day'), 'days', true); var result = '<span title="{0}">{1}</span>'; var tooltip = date.format(UiSettings.longDateTime(true)); var text; @@ -28,4 +28,4 @@ module.exports = NzbDroneCell.extend({ return this; } -}); \ No newline at end of file +}); diff --git a/src/UI/System/Logs/Table/LogsTableLayout.js b/src/UI/System/Logs/Table/LogsTableLayout.js index f7d9430b6..e320ca7fa 100644 --- a/src/UI/System/Logs/Table/LogsTableLayout.js +++ b/src/UI/System/Logs/Table/LogsTableLayout.js @@ -8,6 +8,7 @@ var GridPager = require('../../../Shared/Grid/Pager'); var LogCollection = require('../LogsCollection'); var ToolbarLayout = require('../../../Shared/Toolbar/ToolbarLayout'); var LoadingView = require('../../../Shared/LoadingView'); +var ErrorView = require('../../../Shared/ErrorView'); require('../../../jQuery/jquery.spin'); module.exports = Marionette.Layout.extend({ @@ -57,6 +58,7 @@ module.exports = Marionette.Layout.extend({ this.collection = new LogCollection(); this.listenTo(this.collection, 'sync', this._showTable); + this.listenTo(this.collection, 'error', this._showTableError); this.listenTo(vent, vent.Events.CommandComplete, this._commandComplete); }, @@ -68,6 +70,13 @@ module.exports = Marionette.Layout.extend({ this._showToolbar(); }, + _showTableError : function() { + this.grid.show(new ErrorView({ + title: "Oh snap!", + message: "Failed to load logs, your ad blocker might be blocking the api calls." + })); + }, + _showTable : function() { this.grid.show(new Backgrid.Grid({ row : LogRow, 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/test.sh b/test.sh index 77b58f2e5..cdaa3a497 100644 --- a/test.sh +++ b/test.sh @@ -4,14 +4,20 @@ WHERE="cat != ManualTest" TEST_DIR="." TEST_PATTERN="*Test.dll" ASSEMBLIES="" +TEST_LOG_FILE="TestLog.txt" if [ -d "$TEST_DIR/_tests" ]; then TEST_DIR="$TEST_DIR/_tests" fi +rm -f "$TEST_LOG_FILE" + +# Uncomment to log test output to a file instead of the console +# export SONARR_TESTS_LOG_OUTPUT="File" + NUNIT="$TEST_DIR/NUnit.ConsoleRunner.3.2.0/tools/nunit3-console.exe" NUNIT_COMMAND="$NUNIT" -NUNIT_PARAMS="--teamcity" +NUNIT_PARAMS="--teamcity --workers=1" if [ "$PLATFORM" = "Windows" ]; then WHERE="$WHERE && cat != LINUX" 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/' } },